Most user-programmable mechanical keyboards use an embedded OS named QMK, which has support for both AVR and ARM microcontrollers. Some keyboards alternatively use ZMK which adds support for Bluetooth.
However as it turns out, the Rust-on-ARM story is pretty fleshed out with the following projects:
rtic-rs/cortex-m-rtic: Provides support for concurrently executing tasks using interrupts.
embassy-rs/embassy: Provides support for concurrently executing tasks using the rust async infrastructure, along with async USB/UART/I2C interfaces.
For this I built an ergo keyboard (the corne v3) with RGB LEDs and OLED displays. For the controller(s) I went with nice!nanos, which use the nRF52840 SoC. The nRF52840 supports Bluetooth, but as I don’t move the keyboard often, I decided to assume the keyboard will be always wired.
This was my first time writing for a microcontroller using something other than Arduino and it was an incredibly fluid experience. I spent a grand total of zero seconds debugging memory related problems, all bugs I had ended up being logic issues that were easily fixed by logging with defmt.
Programming
Embassy
Initially I attempted using RTIC to provide support for concurrent tasks, however RTIC requires you to manage setting up interrupts (for UART, I2C, etc) yourself, and quickly this got annoying.
After finding embassy I quickly ported my code over to use it. Embassy uses rust’s async machinery to allow for true tasks that run forever (in rtic each task is a function triggered by an interrupt). Embassy provides async compatible interfaces for USB, UART, and I2C too.
Creating a UART interface in Embassy is as simple as:
To communicate between tasks, I use the channel provided by embassy to have the producer task wait for the consumer task to process messages if the queue is full.
Keyberon
The most important part of the keyboard is having it, well, work as a keyboard. Luckily I don’t have to implement all the state management of a keyboard myself, as the TeXitoi/keyberon project conveniently exists.
Keyberon was incredibly easy to work with as it splits itself up into the few logically separated components needed for a keyboard:
Matrix polling
Key debouncing
Key event processing
HID event generation
To get Keyberon working I simply had to:
Specify matrix pins and construct the matrix struct
I used a macro for this as it requires that the gpio pin values are partially moved from the peripherals struct (this allows the compiler to ensure there is only one user of each pin at compile time)
Keyberon provides a nice macro for doing this. In the same file I also declare the hold-taps and chords I want to use.
pubstaticLAYERS: Layers =keyberon::layout::layout!{{['`' Q W E R T Y U I O P '\''],[LShift A S D F G H J K L ; RShift],[LCtrl Z X C V B N M ,./ RCtrl],[n n n LGui {ALT_TAB}{L1_SP}{L2_SP} Enter BSpace n n n],[Escape {m(&[KeyCode::LAlt,KeyCode::X])}{m(&[KeyCode::Space,KeyCode::Grave])} Delete <{m(&[KeyCode::LShift,KeyCode::SColon])}>'\\'/'"''\'''_'],}{['`'!@'{''}'|'`'~'\\' n '"' n],[ t #$'('')' n +-/*'\'' t],[ t %^'['']' n &=,.'_' t],[n n n LGui LAlt == Tab BSpace n n n],[n n n n n n n n n n n n],}{[n Kb1 Kb2 Kb3 Kb4 Kb5 Kb6 Kb7 Kb8 Kb9 Kb0 n],[t F1F2F3F4F5 Left Down Up Right VolUp t],[t F6F7F8F9F10 PgDown {m(&[KeyCode::LCtrl,KeyCode::Down])}{m(&[KeyCode::LCtrl,KeyCode::Up])} PgUp VolDown t],[n n n F11F12 t t RAlt End n n n],[n n n n n n n n n n n n],}};
Poll the matrix
Keyberon provides the matrix struct, but won’t handle polling the matrix at an interval for us. For that I use an embassy task to poll the matrix every POLL_PERIOD and feed the results through the debouncer and chording engine. The processed key events are then pushed to a channel for processing by the layout task.
As before with the matrix, we decide when the layout state should be updated. For that another task is used to update the matrix state when a key is pressed or released. This task also receives the key events from the other half.
To extract keycode events we use another task that extracts which keys are currently pressed every 1ms and submits the event to the task handling the USB HID messaging.
My Corne kit came with per-key and under-glow neopixels, so why not use them!
Fortunately the jamesmunns/nrf-smartled library exists for controlling neopixels by ~~ab~~using the PWM peripheral on the nRF52840. Now to take advantage of all 64Mhz we just need to define the positions of each LED (they’re connected serially):
// underglow LEDs are left to right
#[rustfmt::skip]pubconstUNDERGLOW_LED_POSITIONS:[(u8,u8);UNDERGLOW_LEDS]=[// top row: 1, 2, 3
(0,1),(2,1),(4,1),// bottom row: 4, 5, 6
(4,2),(2,3),(0,3),];// switch leds are bottom to top
#[rustfmt::skip]pubconstSWITCH_LED_POSITIONS:[(u8,u8);SWITCH_LEDS]=[// first column: 7, 8, 9, 10
(3,5),(2,5),(1,5),(0,5),// second column: 11, 12, 13, 14
(0,4),(1,4),(2,4),(3,4),// third column: 15, 16, 17, 18
(3,3),(2,3),(1,3),(0,3),// fourth column: 19, 20, 21
(0,2),(1,2),(2,2),// fifth column: 22, 23, 24
(2,1),(1,1),(0,1),// sixth column: 25, 26, 27
(0,0),(1,0),(2,0)];
And now we can create a fancy rainbow effect:
pubfnrainbow_single(x:u8, y:u8, offset:u8)-> Hsv{ Hsv { hue: x
.wrapping_mul(6).wrapping_add(y.wrapping_mul(2)).wrapping_add(offset), sat:255, val:127,}}pubfnrainbow(offset:u8)-> impl Iterator<Item = RGB8>{colour_gen(move|x, y|hsv2rgb(rainbow_single(x, y, offset)))}
And use an embassy task to update and render it at 30fps.
I also added in animated waves that emanate from each key when pressed, I’m really making full use of the nRF’s FP unit here.
OLEDs
The Corne has support for a 128x32 display on each side, enough space that I’m struggling to decide what to put on each.
For the right side I have some metrics displayed: the total number of keypresses, the current keypresses per second, and the number of seconds the keyboard has been on. I also have a sliding display of keypresses at the bottom:
And for the left side I currently have a badly drawn bongo cat, I’m still thinking of what to use the remaining 96x32 pixels for.
To control the OLED displays, I use jamwaffles/ssd1306 which handles writing out a buffer the display, and embedded-graphics to draw text, images, and other geometry.
I use the following to initialize the display and handle turning the display off during periods of inactivity:
Since I’m not using Bluetooth, the right side of the split needs to communicate with the left side, there are a few ways to do this but I went with a UART as it easily allows both sides to send messages to the other.
However a UART has no provisions for error checking (outside of a parity bit) or framing so I have to do that myself.
To handle encoding and decoding of messages I use jamesmunns/postcard which conveniently also handles framing and failure recovery through the use of COBS
To ensure message delivery a uuid and checksum is attached to each command, when one side receives a message and validates the checksum, an Ack message with the same uuid is sent back to the other side. After a command is sent, the keyboard waits a period of time for an Ack before considering the message to have not been received. This period is variable depending on the message sent, keypress events have a longer timeout as duplicated keypresses aren’t a good thing, messages that can tolerate duplication are sent with a lower timeout to decrease latency.
The messages sent between sides are defined as plain rust enums that derive serde::Serialize and serde::Deserialize:
And then are serialized with postcard and transmitted to the other side:
async fntask(self){loop{let val =self.mix_chan.recv().await;letmut buf =[0u8;BUF_SIZE];ifletOk(buf)=postcard::serialize_with_flavor(&val,Cobs::try_new(Slice::new(&mut buf)).unwrap()){let r =self.tx.write(buf).await;debug!("Transmitted {:?}, r: {:?}", val, r);}}}
Other dumb things
Okay so I have a keyboard running firmware on rust, oh and I can also talk to it over USB serial in the same way each half talks to the other. What can I do?
Metrics
With a little bit of code on the keyboard we can have it reply with the keypress counter when queried:
And then with the help of another rust program to periodically request the number of keys pressed from the keyboard (I could do this by keylogging, but that’s not as fun) and export the count to Prometheus, we get a fancy dashboard:
Video playback
Since the nRF52840 is pretty powerful, we can get away with streaming a video to the displays of the keyboard. The left side handles receiving frames from the computer over USB serial, and then sends the frame to its OLED task if the packet is for the LHS, or forwarded to the RHS otherwise.
The OLED tasks on each side have a channel to receive frames from, when a frame is received the task stops rendering the original content for a second and instead displays the received frame.
The result is this:
Links
If you’re interested, you can find the source code here