Without a firmware loaded onto it, an Atreus keyboard is just a pile of key switches wired together. The firmware tells the controller how to report switch state to the operating system. The controller is a tiny computer specialized for simple input/output tasks. It has a number of pins which can be used either to turn voltage on and off (output) or to read the voltage level (input).
It would be simplest to hook each key up to an input pin, but there aren't enough pins for all the keys. To scan all the keys with only 15 pins, it reads only one row at a time using a process called a matrix scan. In addition, the small number of keys on the Atreus necessitates the use of layers of key mappings that the user can switch through quickly.
If you'd prefer to jump ahead to the fully-completed functional firmware, you can read thru the explanation in the comments there, but for a higher-level overview keep reading here. In this article we'll step through the process of writing the firmware incrementally. Starting with the simplest thing that could possibly work (a single key) we'll add features until we have a full-fledged matrix-scanning, layered, customizeable firmware.
The Atreus uses the popular ATmega32u4 chip, which is the same as the Arduino Leonardo and offers native USB device support. There are a number of projects that implement keyboard firmwares on this platform, but they're all written in boring old C. Let's see what one would look like if it were written in the Scheme programming language. In particular, we'll be using Microscheme, a new compiler for subset of Scheme that specifically targets the AVR architecture used by these chips.
If you'd like to follow along at home, you can pull down the menelaus project as well as microscheme. After installing the avr-gcc and avrdude prerequisites, run make in microscheme/ and link the microscheme executable to some location on your $PATH. Then run make upload in the menelaus/ directory and activate the reset on your Atreus, and the code should be loaded in.
Note that loading in this code will remove your keyboard's ability to do a reset from within the firmware. To load successive versions or to revert back to the "classic" C firmware, it will be necessary to perform a hard reset; see the docs for the classic firmware for details on this.
(input 11) (high 11) ; activate pullup resistor (for-each output (list 0 1 2 3)) (for-each high (list 0 1 3)) (low 2) (call-c-func "usb_init") (pause 200) (define (loop) (if (low? 11) (call-c-func "usb_send" 0 4 0 0 0 0 0) (call-c-func "usb_send" 0 0 0 0 0 0 0)) (loop)) (loop)
Here we've got the code to check a single key and report whether it's pressed over USB. Each key switch has one side wired into a row and the other into a column. In this case we're checking the state of the A key, which is in the row connected to pin 1 and in the column connected to pin 11.
Pins 0 through 3 are outputs; each corresponds to a row of the keyboard. We'll discuss the logic behind activating different rows in a future installment, but for now we simply deactivate rows 0, 1, and 3 by setting them high and activate row 2 by setting it low. Otherwise we would get spurious reads from other keys connected to pin 11 in that column.
We then set pin 11 high. Setting the state of an output pin simply changes the voltage level of the pin, but on an input pin it is interpreted as activating the pullup resistor for that pin. This prevents "floating" inputs. Remember that we're not strictly in the world of software here; electrical signals work in terms of voltage levels. A disconnected input will float at mid-range voltages, resulting in random-seeming reads as the voltage level drifts above and below the midpoint threshold based on whatever electrical currents happen to be close to the pin. Even the static from a nearby human finger can trigger it. The pullup will cause it to always read high unless it's directly connected to a low-voltage pin. This is exactly what happens when a switch is pressed and the connection between the low row pin and the input column pin is made.
It feels a bit backwards to treat high as deactivated and low as activated, but because of the pullup resistors built-in to each pin, this is the most convenient way to detect switch state. If it helps, you can think of "high" as having the key be in its "up" position and "low" as being pressed down, but of course this is not the actual reason.
Next we bring the keyboard online as a USB device with a call to the C function usb_init using Microscheme's FFI. (The C functions are implemented in the usb_keyboard.c file if you are curious, but for the purposes of the article we treat it as an external library.) A short pause gives the OS time to register the device as connected before we start sending keycodes.
Finally the loop function decides which keycode to send. If the switch is pressed, the circuit from pin 1 to pin 11 will be connected, which means the low voltage of pin 1 will bring pin 11 low. In this case it sends a 4, which is the USB keycode for A. The FFI again calls usb_send. It accepts the modifier state as its first argument, and the following six arguments are normal keycodes, of which we only use one.
Then we loop by calling the loop function from inside itself, a technique known as recursion. In most programming languages, unbounded recursion will cause a stack overflow, but Scheme has special provisions for recursion at the end of a function that allows this to work indefinitely.
At this point we have implemented the simplest, silliest keyboard possible.
Here we've expanded the logic to scan the entire home row.
(include "keycodes.scm") (define columns (list 0 1 2 3 4 5 6 7 8 9)) (define column-pins (vector 11 12 18 19 10 7 8 9 5 6)) (define layout (vector key-a key-s key-d key-f key-g key-h key-j key-k key-l key-semicolon)) (for-each output (list 0 1 2 3)) (for-each high (list 0 1 3)) (low 2) (for-each-vector input column-pins) (for-each-vector high column-pins) ; activate pullup resistors (call-c-func "usb_init") (pause 200) (define (scan-column last n) (if (low? (vector-ref column-pins n)) (vector-ref layout n) last)) (define (loop) (let ((pressed (fold scan-column 0 columns))) (call-c-func "usb_send" 0 pressed 0 0 0 0 0)) (loop)) (loop)
Rather than hard-coding keycodes into the functions like we did with 4 in the last step, we've moved the key layout into the "keycodes.scm" file; it gives names to all the numeric keycodes we need. The other main change is that we have a whole vector of columns instead of just one, so they all need to have pullups activated.
In loop we determine what keycode to send by folding (aka reducing) over a list of columns. The fold function calls scan-column once for each element in the list, and it passes it the list element and an "accumulator". This starts out as the initial value of zero, but the return value of each scan-column call is fed into the accumulator argument of the next call.
If a low pin is found by scan-column, it looks up the keycode for that column in the layout vector and returns that; otherwise it returns the accumulator, which is value returned by the last scan-column. The final return value of the whole fold is passed on to usb_send. We are still only sending a single keycode in a last-scanned-wins manner, but at this point we can spell a few short words like "dad" and "hall". Progress!