Iterative Keyboard Building Tutorial

Building a custom keyboard means wiring key switches to a microcontroller board, which in turn connects to the computer via USB. Unlike other articles, we rapid prototype here, meaning starting with a very simple working keyboard, and then going further. What we need is:

A switch is the thing under the keycap that does the clicking, with the button on top (blue) and two pins on the bottom (copper).

Cherry MX Blue switch
Cherry MX Blue switch, courtesy of Cherry

Both bottom pins are connected to the controller, typically from some output to some input pin. When the switch is clicked, current can flow, and the controller polls this with 1KHz or so, faster than you can click and release. If you have just a few keys, each key could lead to a controller input pin, however no controller has 100 pins. For this reason, keys are chained in rows and columns (called "matrix layout"), where each row leads to an output pin and each column leads to an input pin (or vice versa).

My approach was like this:

  1. Go shopping
  2. Until wares arrrive, simulate keyboard with SimulIDE and a virtual Arduino
  3. Breadboard with Arduino pseudo keyscans
  4. Breadboard with QMK firmware
  5. FreeCAD to design a real keyboard

Just for reference, my sources include an Imgurian example, wiring a keyboard, wiring 2, qmk handwiring, switch guide, keycaps forum, supplier guide [de]; video sources include 3x3 keyb with qmk and diodes, no soldering but 3d-printed switch-caps.

1 Prototype Shopping

Possible parts according to above sources include the Teensy microcontroller (prefer 5V) [alternatives], or any board with an ATmega32u4 processor [compat]. Having previously bought [de] from Reichelt and Funduino, I started with a basic Reichelt shopping list:

The Arduino Micro has 24 usable digital "D" pins, many of which can have multiple modes or functions besides "digital pin", visible in the so-called "pin-out":

Arduino Micro pin-out
Arduino Micro pin-out, courtesy of Arduino

While the red "D" and white "A" names refer to pins on the circuit board, the orange "P" names refer to the pins on the processor, a distinction which you can keep in mind for later.

2 Simulated Keyboard with SimulIDE

We start with a simulation using the open-source SimulIDE [src], with a starter guide in my Arduino and SimulIDE Tutorial. There is a "keypad" element that already organizes buttons in rows and columns, similar to what we are trying.

SimulIDE keypad circuit with an Arduino Nano
SimulIDE keypad circuit with an Arduino Nano

The keypad in the center is steered by "one input pin per column" (D2, D4, D6) and "one output pin per row" (D8, D10, D12). The "A0 to GND" wiring on the right is for emergency stops. The way any keyboard works is that each of the row pins gets juice for some microseconds, and when some key is pressed, the juice is let through to the appropriate column pin.

Now the naive way would be to read the input pin and expect LOW when there is no juice, and HIGH when there is some. Unfortunately, no juice means no electric potential at all, so input readings will be purely random without a point of reference. So we need a pin mode called "INPUT_PULLUP" [guide].

An Arduino program has two functions: setup() is called once, and loop() is called 50 times per second or so, depending on the processor. So either use some delay() calls, or better, measure timing by yourself with millis(). Note that the timing below is deliberately slow so you can see something in the SimulIDE Serial monitor -- in reality, this all happens much faster.

//#include "Keyboard.h" // not possible with SimulIDE

#define exitPin A0
byte colPins[] = {6, 4, 2};
byte rowPins[] = {8, 10, 12};
const int rowCount = sizeof(rowPins) / sizeof(rowPins[0]);
const int colCount = sizeof(colPins) / sizeof(colPins[0]);
const byte keyCodes[][colCount] = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};

int t0, led_t0;
int activatedRow = 0;
bool led_on;
bool is_running;
bool keyDown[rowCount][colCount]; // detect duplicate presses

void setup() {
  Serial.begin(9600);
  t0 = led_t0 = millis();
  for (int j = 0; j < colCount; j++) {
    pinMode(colPins[j], INPUT_PULLUP);
  }
  for (int i = 0; i < rowCount; i++) {
    pinMode(rowPins[i], OUTPUT);
    digitalWrite(rowPins[i], HIGH); // "off" for "input pullup"
  }
  for (int i = 0; i < rowCount; i++) {
    for (int j = 0; j < colCount; j++) {
      keyDown[i][j] = false;
    }
  }
  pinMode(exitPin, INPUT_PULLUP);
  pinMode(LED_BUILTIN, OUTPUT);
  Serial.print("keyb01 ready, rows=");
  Serial.print(rowCount);
  Serial.print(", cols=");
  Serial.print(colCount);
  Serial.print(", t0=");
  Serial.println(t0);
  
  led_on = false;
  is_running = true;
}

void loop() {
  // Short timing (emergency break).
  const int t1 = millis();
  const int ts = abs(t1 - t0);
  if (ts < 200) {
    return;
  }

  // Pause operations?
  if (digitalRead(exitPin) == LOW) {
    is_running = !is_running;
    Serial.print("Running=");
    Serial.println(is_running);
    t0 = t1;
  }
  if (!is_running) {
    return;
  }
  
  // Long timing (key presses).
  if (ts < 500) {
    return;
  }

  // Activate most current keyboard row.
  const byte oldRowPin = rowPins[activatedRow];
  digitalWrite(oldRowPin, HIGH); // "turn off" for "input pullup"
  activatedRow = (activatedRow + 1) % rowCount;
  const byte newRowPin = rowPins[activatedRow];
  digitalWrite(newRowPin, LOW); // "turn on" for "input pullup"
  Serial.print("On row=");
  Serial.print(activatedRow);
  Serial.print(", t1=");
  Serial.print(t1);
  
  // Ask for any inputs.
  bool anyKey = false;
  for (int j = 0; j < colCount; j++) {
    const byte colPin = colPins[j];
    const int colState = digitalRead(colPin);
    if (colState == LOW) { // "low" == pressed for "input pullup"
      Serial.print(", scan col=");
      Serial.print(j);
      anyKey = true;
      if (!keyDown[activatedRow][j]) { // avoid duplicate key presses
        keyDown[activatedRow][j] = true;
        const byte keyCode = keyCodes[activatedRow][j]; // row, col
        Serial.print(", send=");
        Serial.print(keyCode);
        // Keyboard.press(keyCode);
      }
    }
    else { // col state "high" == released for "input pullup"
      keyDown[activatedRow][j] = false;
    }
  }
  Serial.println();

  // Activate internal LED if any key pressed.
  if (!led_on && anyKey) {
    led_on = true;
    led_t0 = t1;
    digitalWrite(LED_BUILTIN, HIGH);
  }

  // After a second, turn internal LED off again.
  if (led_on) {
    const int led_ts = abs(t1 - led_t0);
    if (led_ts > 1000) {
      led_on = false;
      digitalWrite(LED_BUILTIN, LOW);
    }
  }

  t0 = t1;
}

At top, you see some static variables for current time (an int that starts at 0 and at some point flips over into negative values), and some states. LOW and HIGH are for 0V and 5V (or 3.3V depending on the output pin). Floating points are not supported, but bool is.

The setup() block starts the Serial monitor where you can print debug messages; then uses pinMode() [doc] to set each pin [doc] either to input or output mode, you can only have one per pin. LED_BUILTIN (D13) is the "virtual pin" for the green LED that is somewhere on the board.

The loop() block uses digitalRead() and digitalWrite() functions to change pin state. Note the time span check with millis(), because typically you dont want to write to pins with 16MHz. Also note that print() is rather tedious, as there is no printf-like function.

We sequentially give current to D8, D9, D10 (rows) and more or less immediately ask D2, D4, D6 (columns) whether any of them saw anything. Due to the way INPUT_PULLUP works, "no signal" results in HIGH and "signal coming" results in LOW, leading to somewhat confusing src. Since we cannot send anything as an actual HID, copious Serial printing gives us more detailed information than LED_BUILTIN can provide.

A0 providing a "pause" function is pretty useless in a simulator, but might be a good idea when going to real hardware.

3 Hardware Keyboard with ArduinoIDE

Up next is some real circuits, again with a starter guide in my Arduino and SimulIDE Tutorial. My first breadboard build with 6 keys looks like this:

My breadboard variant kr01
My breadboard variant kr01

Input pins D5, D6 misuse the central +- bus to receive signals from the diodes on the switches, while output pins D2, D3, D4 put some juice on the columns. Since the Cherry MX keys are hard to put onto a breadboard, some standard buttons have taken their function. Just to take the same method as seemingly all the tutorials and videos, I have included diodes [tutorial].

Before you start the Arduino program, choose Tools→Manage libraries or Ctrl+Shift+I and install the "Keyboard" and "Mouse" libraries. As a small refresher, Ctrl+Shift+M is the Serial monitor, Ctrl+R is compile and Ctrl+U is upload. The program itself differs only minimally from the SimulIDE variant.

#include "Keyboard.h"

#define exitPin A0
byte colPins[] = {2, 3, 4};
byte rowPins[] = {5, 6};
const int rowCount = sizeof(rowPins) / sizeof(rowPins[0]);
const int colCount = sizeof(colPins) / sizeof(colPins[0]);
const char keyCodes[][3] = {{'a', 'b', 'c'}, {'d', 'e', 'f'}};

int t0;
int activatedRow = 0;
bool is_running;

void setup() {
  Keyboard.begin();
  t0 = millis();
  for (int j = 0; j < colCount; j++) {
    pinMode(colPins[j], INPUT_PULLUP);
  }
  for (int i = 0; i < rowCount; i++) {
    pinMode(rowPins[i], OUTPUT);
    digitalWrite(rowPins[i], HIGH); // "off" for "input pullup"
  }
  pinMode(exitPin, INPUT_PULLUP);
  pinMode(LED_BUILTIN, OUTPUT);
  digitalWrite(LED_BUILTIN, LOW);
  is_running = true;
}

void loop() {
  // Pause between row scans.
  const int t1 = millis();
  const int ts = abs(t1 - t0);
  if (ts < 10) { // fast pull rate
    return;
  }
  t0 = t1;

  // Pause operations?
  if (digitalRead(exitPin) == LOW) {
    is_running = !is_running;
    digitalWrite(LED_BUILTIN, is_running ? LOW : HIGH);
    delay(500); // avoid duplicate presses
  }
  if (!is_running) {
    return;
  }
  
  // Activate most current keyboard row.
  const byte oldRowPin = rowPins[activatedRow];
  digitalWrite(oldRowPin, HIGH); // "turn off" for "input pullup"
  activatedRow = (activatedRow + 1) % rowCount;
  const byte newRowPin = rowPins[activatedRow];
  digitalWrite(newRowPin, LOW); // "turn on" for "input pullup"

  // Ask for any inputs.
  bool anyKey = false;
  for (int j = 0; j < colCount; j++) {
    const byte colPin = colPins[j];
    const int colState = digitalRead(colPin);
    if (colState == LOW) { // "low" == pressed for "input pullup"
      anyKey = true;
      const char keyCode = keyCodes[activatedRow][j]; // row, col
      Keyboard.press(keyCode);
      Keyboard.releaseAll();
    }
  }
  if (anyKey) {
    delay(200); // key refresh rate
  }
}

Now the Keyboard [doc] [ref] include is allowed, methods like press() [ref] and release() [ref] are available, provided that Keyboard.begin() [ref] was called in the setup. The keycodes are now char because that is what press() expects. I dont use special modifiers [ref] in this first prototype. The timings are much faster and all Serial output is deactivated because it would be too much. For the moment, the "key down" bool array is also omitted in favor of a post-keypress delay().

With INPUT_PULLUP [guide], there is one notable observation: The flow of current appears to be from the input to the output pin. The inserted diodes prove that point. As far as I understood INPUT_PULLUP, it goes like this when compared to INPUT:

  1. INPUT→GND: If button not pressed, no potential difference present => voltage is random, no current flows => randomly LOW or HIGH. If button pressed, INPUT potential is higher than GND => current flows from INPUT to GND => reads LOW
  2. INPUT_PULLUP→GND == INPUT→GND or VCC: If button not pressed, VCC→INPUT current flows => HIGH. If button pressed, INPUT→GND current flows => LOW.

So INPUT and INPUT_PULLUP both produce LOW when the button is pressed, and the current flows from INPUT to GND.

And now go to your Terminal window and type some letters with the six input buttons. Cool!

4 Hardware Keyboard with QMK

While it is possible to hardcode the keyboard with Arduino, QMK is a better option that only requires python3 and git to be installed [setup] [build] [flash] [keycodes].

sudo apt-get install git python3-full python3-pip
python3 -m venv /opt/python3-venv
/opt/python3-venv/bin/pip3 install qmk
/opt/python3-venv/bin/qmk setup -H /opt/qmk
/opt/python3-venv/bin/qmk doctor

cd /opt/qmk
/opt/python3-venv/bin/qmk list-keyboards
/opt/python3-venv/bin/qmk compile -kb clueboard/66/rev3 -km default

qmk new-keyboard
    name: kr01_2x3
    github user: megid2
    real name: KR
    default layout: 30 (ortho_2x3)
    mcu: 28 (atmega32u4)

cd /opt/qmk/keyboards/kr01_2x3

nano -w info.json
    "bootloader": "caterina"
    "diode_direction": "ROW2COL",
    "matrix_pins": {
        "cols": ["D4", "D0", "D1"], // PD4=D4, PD0=D3, PD1=D2
        "rows": ["D7", "C6"]        // PD7=D6, PC6=D5
    }

nano -w keymaps/default/keymap.c
    const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
      [0] = LAYOUT_ortho_2x3(
        KC_A,    KC_B,    KC_C,
        KC_D,    KC_E,    KC_F
      )
    };

/opt/python3-venv/bin/qmk compile -kb kr01_2x3 -km default
    # lands in /opt/qmk/.build

/opt/python3-venv/bin/qmk flash -kb kr01_2x3 -km default
# when prompted, click reset button on arduino board
# (the white one besides the 2x3 pin group)

/opt/python3-venv/bin/qmk flash -kb kr01_2x3 -km default

# to ease keyboard choice:
qmk config user.keyboard=kr01_2x3
qmk config user.keymap=default

# if "qmk console" has a HIDException
cp /opt/qmk/util/udev/50-qmk.rules /etc/udev/rules.d/54-qmk.rules
udevadm control --reload-rules; udevadm trigger
qmk doctor

qmk console -l
qmk console -n -t

Pin names must be the one from the atmega32u4 processor [specs], not from the Arduino board. That means the pins starting with a "P" as specified on the processor.

ATmega 32u4 pin-out
ATmega32u4 pin-out, courtesy of Microchip

... but using the name without the "P" in the QMK source. This creates a nice confusion between the Arduino board pins and the ATmega32u4 processor pins; not sure what the QMK devs were thinking there. Luckily, the processor pins are also shown on the Arduino micro pin-out, see the Arduino Micro pin-out figure at the top of the page.

After flashing, the keyboard should already have registered as a HID device, check dmesg for that. Now try the keys again, they should nicely arrive in the Terminal and anywhere else.

5 Real Keyboard with QMK

Now that we have the prototype in place, work on the real keyboard can begin, and by that I mean 3d-printed and taking real switches. And that means FreeCAD, with a starter guide in my FreeCAD tutorial. My basic design is a base plate which receives lots of keyswitch holders.

FreeCAD model of my kr02 keyboard, left hand
FreeCAD model of my kr02 keyboard, left hand

For the left hand, each finger gets a "vertical" strip for 6 keys, and pinkie finger (left) and index finger (right) get two strips. The rows are from top to bottom: F keys, numbers, q row, a row, x row, space row. The base plate at the bottom has screw holes for the strips, and some pre-formed channels for row and column wires, as well as house-shaped holes to attach wrist rests, track bar and thumb keys later on.

Normally, a 1u switch hole is 14mm squared within a 19.05mm pad, leading to a 2.525mm distance from the border.

Keycap dimensions, top view
Keycap dimensions viewed from top, courtesy of The Gaming Setup

However since 3d printers always have a bit of excess material, I shave it down to 14.15mm and 2.45mm distance from the border. A wire channel comes 5.85mm from each corner. When planning distances, also allow for 10mm vertical space from the keystrip top plate to the ground plate.

Cherry MX blue dimensions
Cherry MX blue dimensions, courtesy of Cherry

Note that the strict necessary distance from the keystrip top plate to the ground plate is 8.3mm (5+3.3), but a bit more for soldering tin and wire is recommended. Initially I did not use the plastic sticks at the keyswitch bottom at all, however after it became apparent that the copper keyswitch contacts are prone to breaking, I created a "sled" for each key with holes for all five.

Keyswitch sled and holder, separated
Keyswitch sled and holder, separated

The sled contains three holes for the plastic pins of the keyswitch, and two holes that stabilize the copper contacts. Rails on both sides fit into the notches of the test keystrip, printed prior to re-designing the real ones. The triangular holes on the side walls are for orientation.

Keyswitch sled and holder, slightly open
Keyswitch sled and holder, slightly open

For keycaps [guide] I chose DSA profile, 1u only, PBT keycaps [amazon]; apparently group buys are common and no supplier has real stock.

Keycap profiles
Keycap profiles, courtesy of Keeblog

Not all keycaps fit onto the Cherry MX switches, e.g. the MBK keycaps require Kailh Choc v1 switches instead, as used on the MoErgo Glove80 split keyboard. The DSA keycaps are uniform over all rows and reasonably low without going into laptop "too low" territory. They are 18x18mm at the base and 12x12mm at the top, with less than 8mm height; together with the switch, the height from the base plate is less than 15mm. With angles between keys up to 25°, we have to increase the inter-key distance of 19.05mm a bit further to avoid collisions:

Inter-key 20° angle with collision-free distances
Inter-key 20° angle with collision-free distances

So when the keys are on a flat surface, 19.05mm suffice plenty, with 5° angle 19.4mm, with 10° angle 20mm, with 15° angle 20.4mm, with 20° angle 21mm, and with 25° angle 22mm [FCStd].

All those spacing considerations go into the keystrip CAD model. When in doubt I often took inspiration from the Glove80 [journey]. And when we have the STL triangle mesh from the CAD file ready, it is time to start filament printing, with a starter guide in my 3D printing tutorial. The strips take about 1 hour each, and the base plate a whopping 12 hours (note to self: next time, split into several parts).

Printed kr02 keyboard chassis and 6 keystrips with switches and keycaps
Printed kr02 keyboard chassis and 6 keystrips with switches and keycaps

The 3d printed keystrips are for the left hand, where each finger gets one strip and the outer ones two, with "if" for index finger, "mf" for middle finger, "rf" for ring finger and "pf" for pinkie. For visualization inside the image, your (left) hand comes from the right image border.

For soldering [imgur] row and column wires to the switch contacts, I recommend using an automatic wire stripper [reichelt] in the middle of the wire and shifting the (rather long) insulation pieces towards the end, then forming small loops with the exposed sections to fit around the switch contacts, which will make soldering easier. Also take different colors for each column, and for each row, otherwise this will be a nightmare to connect later on.

Wire loops for easier soldering
Wire loops for easier soldering

For 6 keys per strip and 6 strips, both "vertical" (column) wires along one strip, and "horizontal" (row) wires over all strips, need 6 loops. Stranded wire is more flexible and easier to form, while solid wire is more robust and can be put directly into the breadboard. After first using solid wire lead to keyswitch contacts breaking off during keystrip changes, I pivoted to stranded wire (and the aforementioned sleds for contact stabilization). Solid wire was of type "22 AWG" [amazon], (0.33mm², ø0.64mm) and stranded of type "24 AWG" [amazon] (0.2mm², ø0.51mm), slightly thinner.

For holding I tried to use a "third hand" [reichelt], but since the keyswitches kept popping out of the strip, a self-printed "keystrip holder" with Lego support was a better solution; allow 150µm extra tolerance on both the 19.05mm keystrip ends and the 16x16mm Lego stones, e.g. the Lego holes are 16.15mm wide. Cable was fixed with Tesa strips before soldering because the wire too kept popping away despite the tiny loops; later I even printed tiny "cable holders" that attach to one side of each keyhole, and the "popping out" vanished with the sleds.

Keystrip in 3d-printed holder, wire fixed with Tesa
Keystrip in 3d-printed holder, wire fixed with Tesa

For soldering I used a ion-battery soldering iron [amazon] and 1mm tin with integrated flux [reichelt]. Also had to take a thicker soldering tip than I expected, the thin ones have insufficient melting power. I can also recommend using a spiral metal wool cleaner [reichelt]. In a first go, I did the column wire, then in a second session the 6 row wires at their respective end loops.

Leftmost keystrip with one column wire and 6 row wire ends
Leftmost keystrip with one column wire and 6 row wire ends

The finished keystrip "pf0" then went into the base plate. Time to put it into the breadboard for testing! Since stranded wire does not go into the breadboard holes very well, I soldered and heat-shrinked a bit of solid wire to it; Crimping [reichelt] would be a better alternative if I had to do this more often.

Keyboard kr02, strip pf0 on breadboard
Keyboard kr02, strip pf0 on breadboard

The one-keystrip breadboard test used the previous QMK program and therefore just three of the keys, so testing required exchanging wires on the breadboard. Time to solder on the next 5 strips, or as it turned out, redesign all 6 keystrips with sled notches, then print and wire them.

Keyboard kr02, six strips on breadboard
Keyboard kr02, six strips on breadboard

More keys mean fresh QMK keycodes in keymap.c, mostly variants of KC_1, KC_A, KC_LEFT_SHIFT and so on. One interesting option is mouse keys, e.g. as KC_MS_BTN1; this also requires editing info.json and set "mousekey": true in the "features" section. Cool keys include QK_GESC instead of KC_GRV [qmk] which combines Escape with angle ° (shift) and roof ^ (win), and SC_LSPO instead of KC_LEFT_SHIFT [qmk] which types parenthesis () when shift is clicked not held. Sadly, Fn is not a real key; however with the keymap mod "layers" you can make your own one.

To counteract my german keymap, I have to temporarily switch to an english layout. Then it is time for the update [keymap.c] [config.h] [info.json]:

qmk config user.keyboard=kr02_left_6x6
qmk config user.keymap=default
qmk compile
qmk flash

The last command waits for you to press the reset button on the Arduino micro.

Attachments like breadboard holder and palm rest can be screwed on. M3 screws suffice plenty [amazon]; in FreeCAD, threaded rods (Gewindestange) need circles of 3.1mm diameter to fit in, heads (Schraubkopf) need 5.5mm diameter and 3mm height, and nuts (Muttern) need a hexagon with 6mm between parallel sides and 2.5mm height. Square nuts (Vierkantmutter) [amazon] in flat version require 5.8mm side length and 2mm height, and are easier to slide into the print.

Keyboard kr02, with attachments for palm rest and mini breadboard
Keyboard kr02, with attachments for palm rest and mini breadboard

And so at long last, we have a whole experimental left-hand keypad [FCStd], with single key exchangeability! Now we can go iterating on the key strips some more. I hope you got some inspiration on this page, and go on to make your own keyboard. Good luck and have fun!

EOF (Oct:2024)