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:

  1. as part of the rust project that builds the firmware;
  2. 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.


1

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:

  1. mouseaccel<n> where <n> is 1, 2, or 3. mouseaccel1 will change the mouse movement and scroll accelerations characteristics from the default mouseaccel2 as will mouseaccel3 usually 1 will be slower and 3 will be faster than the default.
  2. stop_active will release any held keys and clear the modifier layers.
  3. clear_layers will deactivate all layers expect the base layout.
  4. clear_all will stop all actions/macros, clear all layers, restore the base layout to main, and release all keys.
  5. reset_keyboard will restart the keyboard firmware as i f it had just been powered on.
  6. 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:

  1. macro(<expr>)
  2. hold(<expr>)
  3. release(<expr>)
  4. <modifier-list>-<keycode> (modifier-macro)
  5. <unicode-char>
  6. 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.


1

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.