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).
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:
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.
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":
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.
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.
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.
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:
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:
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!
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.
... 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.
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.
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.
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.
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.
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.
For keycaps [guide] I chose DSA profile, 1u only, PBT keycaps [amazon]; apparently group buys are common and no supplier has real stock.
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:
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).
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.
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.
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.
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.
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.
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.
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)