The Rust Programmable Keyboard Firmware Builder
RPK is a set of rust crates to build and configure hobbyist mechanical keyboard firmware. It differentiates itself form other firmware builders—such as QMK—by the way the keyboard is configured. Instead of fixed numbers of layers and macros with an assigned action to each row/column RPK uses the configuration model of keyd which allows for many more layers and macros.
The current features of RPK include:
- Text file configuration which can be uploaded instantly via the
rpk-config
companion program (no need to re-flash firmware). - 256 low cost layers (first 32 can be composite).
- 4096 macros.
- Sensible key overloading, oneshot layers and changeable base layout.
- Modifiers are layers.
- Mouse support with changeable acceleration profiles.
- n-key rollover, consumer and sys ctl keycode support.
- Unicode support.
- Ring file system for storing multiple configurations.
- Clear, reset, bootloader actions and reset on panic.
- Low latency debounce logic.
- Low overhead firmware - uses rust embassy async embedded framework.
Installation
In order to build a custom keyboard firmware you need to install the rpk-config
tool. This
requires that the rust programming language is installed (See Install
Rust).
Once you have rust installed you can install the rpk-config
tool.
Not strictly necessary but expected by the default settings are the packages: flip-link
and
elf2uf2-rs
.
Install these packages using the following command:
cargo install rpk-config flip-link elf2uf2-rs
Installing the latest master version
The version published to crates.io will sometimes be behind the version hosted on GitHub. If you need the latest version you can build it using:
cargo install --git https://github.com/jacott/rpk.git rpk-config
Creating a keyboard
While this guide won’t help you build a physical keyboard it will help you how to create firmware for it.
Initializing a firmware project
At present only the rp2040 microcontroller (MCU) is supported. RPK might work for other MCUs but this guide is geared towords the rp2040. It may serve as a starting point; see the embassy project for more help to configure the project files.
The rpk-config init
command will create a new directory containing an skeleton project for you to
get started. Give it the name of the directory that you want to create:
rpk-config init my-first-keeb
It will ask a few questions before generating the project. After answering the questions, you can change the current directory into the new project:
cd my-first-keeb
Project Structure
Firmware is built from several files which define how to create a binary that can be flashed to the MCU:
my-first-keeb
|- .cargo
| |- config.toml
|- src
| |- main.rs
|- build.rs
|- Cargo.toml
|- memory.x
|- default-layout.rpk.conf
The .cargo/config.toml
describes what platform you’re on, and configures how to deploy your
keyboard. build.rs
and src/main.rs
is the rust code to run the firmware; these should not need
any modification. Cargo.toml
describes the rust packages needed to build the firmware. memory.x
describes the MCU memory layout. See the Embassy Project Layout and RPK API for more
details.
The default-layout.rpk.conf
determines how pins on the MCU generate key-codes. This is described
in detail in The Config File Firmware section.
Once you have edited the above files appropriately you can flash the software to the keyboard using
the cargo run
command. You may need to put the keyboard in to boot select mode first; this is done
on a Raspberry Pi by holding down the reset button before connecting to the usb port or the
rpk-config reset --usb-boot
command if the keyboard is already running RPK firmware. Next run the
following command from within the project to reflash the device:
cargo run --release
Config File
The config file contains most of the details for building and modifying a keyboard. It is based on the keyd mapping config file format with a some missing features, some extra features and configuration options needed for a mechanical keyboard. Notably the firmware section defines most of the parameters needed to convert key switch presses in to sending USB HID messages to the host computer. The config file can be used in two places:
- as part of the rust project that builds the firmware;
- as input to the
rpk-config
command line tool to instantly change the behavior of a running keyboard.
Configuration files loosely follow a INI style format consisting of headers of the form
[section_name]
followed by a set of bindings. Lines beginning with a hash #
are ignored.
A valid config file must at least have a matrix section.
Special characters like brackets [
can be escaped with a backslash \[
.
Firmware Section
The firmware section is mostly used to build the keyboard firmware but can also be used to determine
which keyboard to reconfigure (see Command Line Tool). This is done using the the fields:
vendor_id
, product_id
, and serial_number
.
Here is an example firmware section
[firmware]
# USB interface
# =============
vendor_id = 0xceeb # 4 hex-digit number; try to be unique.
product_id = 0xb0ad # 4 hex-digit number; try to be unique per vendor_id.
serial_number = rpk:1234 # must start with rpk: unique per product, vendor_id.
# All of the following fields are only used when building the keyboard firmware binary.
manufacturer = Jacott
product = RPK macropad
max_power = 100 # 0 to 500 (mA). Default 100
# Chipset and pin assignments
# ===========================
chip = rp2040
# Pin names are from the chipset crate. For rp2040 it is the embassy_rp crate
output_pins = [PIN_4, PIN_5, PIN_6]
input_pins = [PIN_7, PIN_8, PIN_9]
row_is_output = true # the output pins are connected to the keyboard rows
# Memory allocation
# =================
# Flash ring file system used to hold layouts, security tokens, dynamic macros...
flash_size = 2 * 1024 * 1024 # 2MB matches memory.x file
fs_base = 0x100000 # room for other things like firmware
# ensure fs_size is multiple of flash erase size (4K)
fs_size = flash_size - fs_base
# How much room to reserve for layout configuration + runtime requirements.
max_layout_size = 8 * 1024
# How many messages can we queue to the usb interface without waiting.
report_buffer_size = 32
# How many key events can we scan without waiting.
scanner_buffer_size = 32
The flash_size
corresponds to the memory.x
flash desription. Currently only chip = rp2040
is
supported.
Global Section
Keyboard wide values are defined in this section and can contain any of the following options:
dual_action_timeout = <milliseconds>
How long to wait for a tap to occur on an overloaded key. overload_tap_timeout
is an aliases for
this option. The default is 180ms.
dual_action_timeout2 = <milliseconds>
How long to wait after another two keys are pressed (or released) before giving up on waiting for a tap. The default is 20ms.
debounce_settle_time = <milliseconds>
How long to wait for a key press or release to settle before reporting the next change in state. The timer starts from the last bounce detected; so a noisy key will take longer to settle than a stable key. The default is 20ms. In essence this dictates how minimum report time between a key press and release or release and press. Allowed values range from 0.1ms to 25.0ms.
unicode_prefix = <action>
The action to run before sending a unicode sequence.
unicode_suffix = <action>
The action to run after sending a unicode sequence.
[global.mouse_profile<n>.movement] (or .scroll)
Where <n>
may be 1, 2, or 3. Is a subsection detailing the acceleration profile of the mouse
movement (or mouse scroll). The following subfields are allowed:
curve = [<s>, <e>]
: where<s>
and<e>
are floating point numbers specifying the “x” part of the control points of a bezier curve (0 to 1). If<s>
is 0 then the accerlation is slow to change in the begining; if it is 1 then it is fast to change at the start. Conversely if<e>
is 0 then the accerlation is fast to change at the end; if it is 1 then slow to change at the end.max_time = <milliseconds>
: How long it takes to get to the end of the bezier curve.min_ticks_per_ms = <milliseconds>
: The absolute minimum speed of the mouse.max_ticks_per_ms = <milliseconds>
: The absolute maximum speed of the mouse.
Example
[global]
unicode_prefix = C-S-u
unicode_suffix = macro(return delay(20))
overload_tap_timeout = 180
[global.mouse_profile1.movement]
curve = [.2, 1]
max_time = 1000
min_ticks_per_ms = .1
max_ticks_per_ms = 5
[global.mouse_profile1.scroll]
curve = [1, 0]
max_time = 5000
min_ticks_per_ms = .01
max_ticks_per_ms = .1
Matrix Section
This section gives the keyboard’s individual switches a symbolic name. See the firmware section to
see how to configure the MCU pins into rows and columns. The matrix section identifier takes an
additional suffix which defines how many rows and columns are being mapped1 like
[matrix:4x12]
which would indicate 4 rows and 12 columns. Assignments in this section start with a
row-column id assigned to one or more symbols. Matrix is the only required section in a config file.
Example
[matrix:4x3]
0x00 = 7 8 9
0x10 = 4 5 6
0x20 = 1 2 3
0x30 = 0 . -
The left-hand-side of the assignment is a matrix location (row, column) in hexidecimal—indicated
by the 0x
prefix—which is partitioned into rows and columns; 0x15
for example would indicate
row 1 column 5. If there are more than 16 rows or columns then four digit hex numbers can be used
like 0x0a13
for row 10 column 19. One does not need to define a whole row per assignment but only
one row can be assigned per line.
When a valid keycode is used to name a keyboard switch it will be assigned by default to the main layout.
Complex Example
[matrix:2x3]
0x00 = 7
0x01 = 8 9
0x10 = 4
0x12 = return
0x11 = k11
In the complex example the first row is defined in two assignments and the second row defines each
key separately. Note that k11
is not a valid keycode—it will map to noop
(No
operation)—but can be used instead of 0x11
in other parts of the config file. So 0x12
,
0x0102
, return
all refer the keyboard switch at row 1, column 2 which is mapped by default to
keycode return
.
Aliases Section
You can give a matrix location additional names using the aliases section. The main use of this is to give an additional name to multiple switches which will then allow assigning a action/keycode to multiple switches with one assignment.
Example
[matrix:3x3]
0x00 = a b c
0x10 = d e f
0x20 = g h i
[aliases]
g = hyper
i = hyper
[main]
hyper = overload(nav, space)
[nav]
b = up
d = left down right
This example means that if g
or i
is tapped a space
will be emitted and will
switch to the nav
layer if held.
The matrix suffix can be a bit redundant if the firmware section defining pins is present but since the firmware section is optional we always need to define it as part of the matrix section header.
layers
Layers allow keyboard switches to execute more than one action or keycode. Multiple layers may be
active at any given time. At least one layer is always active and is known as the base layout. The
base layout defaults to [main]
which is initially defined by the matrix section.
Each layer contains a list of assignments which alter the keycode/action produced by a keyboard switch matrix location. Each assignment is of the form:
<location> = <action-list>
Where action list is a space separated list of actions and/or keycodes.
Example
[matrix:3x3]
0x00 = a b c
0x10 = d e f
0x20 = g h i
[main]
g = overload(nav, g)
[nav]
b = up
d = left down right
g = layer(shift) macro(hello) layer(control)
Modifiers
Besides the [main]
layer there are five other layers that are always defined: [control]
, [shift]
,
[alt]
, [gui]
, and [altgr]
. These are the modifier layers and are bound to the modifier keycodes.
This means that when, say the left (or right) control key is held, the [control]
layer will become
active. The same applies to [shift]
and [gui]
. [alt]
relates to the leftalt
keycode, [altgr]
refers to the rightalt
keycode. These modifiers can be applied to any user defined layer in the
form of a layer suffix. This makes the layer behave like a modifier layer. The format of the suffix
is a follows:
"[" <layer-name>[:<modifier-list>] "]"
Where <modifier-list>
has the form:
<modifier>[-<modifier>]...
and each modifier is one of:
- C - Left Control
- S - Left Shift
- A - Left Alt
- G - Letf GUI (Meta)
- RC - Right Control
- RS - Right Shift
- RA - Right Alt (AltGr)
- RG - Right GUI (Meta)
Example
[matrix:3x3]
0x00 = 7 8 9
0x10 = 4 5 6
0x20 = nav leftshift rightshift
[main] # No modifiers may be applied main
nav = layer(nav)
[nav:A-G]
8 = up
4 = left down right
[shift] # implies the :S suffix (or :RS if invoked by rightshift)
nav = space
When the nav
key is held the [nav]
layer becomes active. Also because it has modifiers the
leftalt
and leftgui
keycodes are sent to the host to report that they are held.
When the 7
key is tapped whilst the nav
key is still held the keycode for 7 will be sent to the
host followed by a release of the 7 keycode; the modifiers remain active through out.
Now if 8
key is tapped, still whilst the nav
key remains held, the host is sent a report
indicating that the leftalt
and leftgui
have been released followed by the press of the up
keycode, then the release of the up
then finally by the reapplication of the leftalt
and
leftgui
.
Now if nav
is finally released then the host will receive a release of leftalt
and leftgui
and
the [nav]
layer will be deactivated.
The [control]
, [shift]
and [gui]
layers differ from other layers when they are made active by
rightcontrol
, rightshift
and rightgui
respectively; in that case the right modifiers will be
reported instead of the left whilst the layer is active. In the case above, holding rightshift
,
nav
will result in the report of rightshift
hold, rightshift
release, space
hold.
Layers can be definied more than once in a conf file but only the first definition can contain modifiers; any subsequent definition with modifiers will ignore the modifiers. The six default layers can never have their modifiers changed.
Composite layers
Layers can be combined to form composite layers. Composite layers are named with existing layers
delimited by a +
. The layer will be active when, and only when, all the constituent names are
active and is given precedence. Only the first 32 layers may be used as a constituent of a composite
layer.
Example
[control+alt]
i = up
This will cause control-alt-i
to send the up
key event while control-alt-j
will preserve the
modifiers and send exactly what is pressed since j
is not defined in the composite layer.
Actions and Keycodes
Keycodes
RPK supports most of the keycodes for the USB HID classes of keyboard, consumer control, system control, and mouse. The symbolic names of these keycodes vary and RPK supports some common aliases. To get a list of all the keycode names run the following command:
rpk-config list-keycodes
Try adding --help
for extra features available for listing the codes.
Other pseduo keycodes are used to change the state of the keyboard.
The special keycodes are:
mouseaccel<n>
where<n>
is 1, 2, or 3.mouseaccel1
will change the mouse movement and scroll accelerations characteristics from the defaultmouseaccel2
as willmouseaccel3
usually 1 will be slower and 3 will be faster than the default.stop_active
will release any held keys and clear the modifier layers.clear_layers
will deactivate all layers expect the base layout.clear_all
will stop all actions/macros, clear all layers, restore the base layout tomain
, and release all keys.reset_keyboard
will restart the keyboard firmware as i f it had just been powered on.reset_to_usb_boot
will restart the keyboard in mass storage mode, if supported, which will allow a new firmware binary to be installed.
Actions
Actions allow for keyboard specific functions to be invoked that take one or more arguments.
layer(<layer>)
Activate the given layer for the duration of the key press.
oneshot(<layer>)
When tapped activate the layer for the next key press only.
setlayout(<layout>)
Replace the base layout.
toggle(<layer>)
Turn on a layer if inactive; otherwise turn off the layer.
delay(<milliseconds>)
Wait the given milliseconds before reporting the next keycode to the host computer.
dualaction(<hold-action>, <tap-action>[, <timeout1>[, <timeout2>]])
Run the <hold-acton>
when held, execute the <tap-action>
on tap. <timeout1>
and <timeout2>
override global.dual_action_timeout
and global.dual_action_timeout2
respectively. A key is
considered held if <timeout1>
expired before no more than two other key events happen; or
<timeout2>
expires before more than two key events happen. <timeout2>
starts running after two
other key events are detected.
overload(<layer>, <action>[, <timeout1>[, <timeout2>]])
Overload is an alias for dualaction(layer(<layer>), <action>[, <timeout1>[, <timeout2>]])
.
Macros
Macro expressions are user defined actions that run a sequence of other actions/keycodes. The following forms are all valid macro expressions:
macro(<expr>)
hold(<expr>)
release(<expr>)
<modifier-list>-<keycode>
(modifier-macro)<unicode-char>
unicode(<hex-digits>)
<expr>
has the form <token1> <token2>...
where each token is one of:
- A valid keycode or action.
- A modifier-macro.
- A contiguous list of unicode characters.
macro()
taps out the expression, hold()
activates the keycodes on a key press and release()
deactivates the keycodes on key release. macro(hold(<expr1>) release(<expr2>))
will activate
<expr1>
on key press and deactivate <expr2>
on key release.
<unicode-char>
is a unicode character not in the basic keycode range; it is converted to
unicode(<hex-digits>)
which will invoke the global.unicode_prefix
action, type out the
hex-digits, and finally invoke the global.unicode_suffix
action.
<modifier-list>-<keycode>
is a list of modifier codes separated by a dash -
(See modifiers)
followed by any valid keycode. This will report the modifiers along with the keycode; for example
S-1
will normally produce a bang !
.
The following are all valid macro expressions:
C-d
hold(C-a)
A-S-backspace
macro(He llo space delay(500) 🌏)
Splitting into smaller tokens serves as an escaping mechainism: macro(space)
inserts a space,
macro(sp ace)
writes “space”.
Modifier macros
Modifier macros report the given modifiers before reporting the keycode
. The modifiers are
reported only when necessary. This makes them useful for keeping layer modifiers in place when
defining keys on that layer.1
Example
[nav:C]
j = left
u = C-left
Here when the nav
layer is active holding j
will result in reporting release leftcontrol
followed by hold left
to the host whereas holding u
will result in just a hold left
being
reported.
Sometimes, when multiple modifiers are pressed, a modifier is released before the modifier-macro is pressed. This would result in the modifier-macro not sending the down event. To help prevent this modifier-macros double set the modifiers.
Command Line Tool
The rpk-config
command line tool is the main way to interact with an RPK keyboard from the host
computer. It can validate and upload new mapping config files, reset the keyboard, and initialize
new keyboard projects among other things. See the instructions on how to install the tool.
Remapping A Keyboard
To remap a keyboard use the following command:
rpk-config upload <path-to-conf-file>
If the config file has a firmware section with a vendor_id
, product_id
, and/or serial_number
then rpk-config
can find the corresponding keyboard automatically. If not you will need to supply
arguments to specify the keyboard if there is more than one. To see the list of connected keyboards
run the command: rpk-config list-usb
.
Note: only devices with serial numbers starting with “rpk:” can be configured.
The config file will first be validated before being sent to the keyboard. You can validate the
config file without uploading by running the rpk-config validate <path-to-conf-file>
command
instead.
Uploading will write a new config to the keyboard which will delete older files if the room is needed. Uploading writes to a different location on the flash each time to preserve the life of the flash.
Once a config is successfully writen to flash the keyboard will clear any active key presses and macros then switch over to the new mapping. If the mapping is corrupt, the keyboard will fall back to the defualt mapping supplied in the firmware. This usually all happens in under 20ms.