RMK

Crates.io Docs Build

RMK is a Rust keyboard firmware crate with lots of usable features, like layer support, dynamic keymap, vial support, BLE wireless, etc, makes firmware customization easy and accessible.

The following table compares features available from RMK, Keyberon, QMK and ZMK:

RMKKeyberonQMKZMK
LanguageRustRustCC
USB keyboard support
BLE keyboard support
Real-time keymap editing🚧
Wired split support
Wireless split support
ARM chips(STM32/nRF/RP2040) support
RISC-V & Xtensa chips support
Mouse keyLimited
Keyboard configurationtoml (easy)Rust code (hard)json + makefile (medium)Kconfig + devicetree(hard)
Layers/Macros/Media key support

Overview

This guide will help you build your own keyboard firmware using RMK and run it on your microcontroller. RMK puts lots of efforts to make keyboard firmware creation easy and accessible.

If you get any questions or problems following this guide, please fire an issue at https://github.com/HaoboGu/rmk/issues.

Beginners Guide

There are two methods for compiling your RMK firmware:

  1. Cloud compilation: compile the firmware using Github Actions, no need to install anything to your computer, ideal for new beginners
  2. Local compilation: compile the firmware at your local machine. This method unlocks more options to do with your firmware, such as using a development version or the Rust API

Choose your preferred method from the sidebar to get started.

Cloud compilation

RMK provides a project-template that you can use to create your firmware easily. The following is a step-by-step tutorial for compiling RMK firmware using Github Actions.

Note: There are some limitations currently for cloud compilation. For example, you cannot edit cargo features in the generated project. If you have any problems when compiling RMK using cloud, please open an issue!

Steps

  1. To get started, open project-template, click Use this template button and choose Create a new repository:

use_template

  1. Input your repository name, and click Create repository

create rmk repository

  1. After the repository is created, there are two config files in the project:keyboard.toml and vial.json:

    • keyboard.toml: this file defines almost everything about your keyboard, follow keyboard configuration to create your own keyboard definition
    • vial.json: this file contains matrix definitions which will be recognized by vial. RMK now uses vial to update the keymap on-the-fly. Follow vial's porting guide to create vial.json for your keyboard.

    you can edit the files directly on Github by clicking the file and then choosing edit this file: edit file. After updating your config, click Commit changes.. to save it: commit change

  2. Once you saved your file, Github Action will automatically run to compile your firmware using your saved config files. Click Action tab on the top bar and you can see there's a workflow running. workflow

    You can also check the compilation log by clicking build/build. After the compilation finished, refresh the page and you can see the compiled RMK firmware under Summary/Artifacts:

    artifacts

  3. Now you get your RMK firmware! RMK provides hex and uf2 firmware that you can use. The final step is to flash the firmware to your microcontroller. Follow the instructions in Flash the firmware section.

Local compilation

This sections describes everything you need to compile RMK firmware on your local machine.

Setup RMK environment

First, you have to setup the Rust development environment and install all the needed components for compiling and flashing RMK.

1. Install Rust

Installing Rust is easy, checkout https://rustup.rs and follow the instructions.

Here is a more detailed guide for installing Rust.

2. Choose your hardware and install the target

RMK firmware runs on microcontrollers. By using Embassy as the runtime, RMK supports many series of microcontrollers, such as stm32, nrf52 and rp2040. Choosing one of the supported microcontrollers makes your journey of RMK much easier. In the RMK repo, there are many examples. The microcontrollers in the examples are safe options. If you're using other microcontrollers, make sure your microcontroller supports Embassy.

The next step is to add Rust's compilation target of your chosen microcontroller. Rust's default installation includes only your host's compilation target, so you have to install the compilation target of your microcontroller manually.

Different microcontrollers with different architectures may have different compilation targets. In case you're using ARM Cortex-M microcontrollers, here is a simple target list.

For example, rp2040 is a Cortex-M0+ microcontroller, it's compilation target is thumbv6m-none-eabi. Use rustup target add command to install it:

rustup target add thumbv6m-none-eabi

nRF52840 is also commonly used in wireless keyboards, it's compilation target is thumbv7em-none-eabihf. To add the target, run:

rustup target add thumbv7em-none-eabihf

3. Install rmkit and other tools

rmkit is a tool that helps you create your RMK project easily. You can use the following command to install rmkit:

cargo install rmkit
# If you have problems installing rmkit on Windows, try the following command to install it:
# powershell -ExecutionPolicy ByPass -c "irm https://github.com/haobogu/rmkit/releases/download/v0.0.9/rmkit-installer.ps1 | iex"

There are several other tools should be installed:

  • flip-link: zero-cost stack overflow protection.

  • cargo-make: used to automate uf2 generation.

  • (optional) probe-rs: used to flash and debug your firmware via debug proble. Here is the installation instruction.

You can use the following commands to install them:

  # Install flip-link
  cargo install flip-link cargo-make

  # Install probe-rs using scripts
  # Linux, macOS
  curl --proto '=https' --tlsv1.2 -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh
  # Windows
  irm https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.ps1 | iex

Create firmware project

You can use rmkit to create the firmware project:

rmkit init

This command will ask you to give some basic info about your project and then create a project from RMK's templates:

$ rmkit init                                                                
> Project Name: rmk-keyboard
> Choose your keyboard type? split
> Choose your microcontroller nrf52840
⇣ Download project template for nrf52840_split...
✅ Project created, path: rmk-keyboard

Now RMK has project templates for many microcontrollers, such as nRF52840, rp2040, stm32, esp32, etc. If you find that there's no template for your microcontroller, please feel free to add one.

Update firmware project

The generated project uses the keyboard.toml file to config the keyboard. These steps you have to do to customize your own firmware:

Edit keyboard.toml

The generated keyboard.toml should have some fields configured from rmkit init. But there are still some fields that you want to fill, such as the pin matrix, default keymap, led config, etc.

The Keyboard Configuration section has full instructions on how to write your own keyboard.toml. Follow the doc and report any issues/questions at https://github.com/HaoboGu/rmk/issues. We appreciate your feedback!

Update memory.x

memory.x is the linker script of the Rust embedded project, it's used to define the memory layout of the microcontroller. RMK enables the memory-x feature for embassy-stm32, so if you're using stm32, you can just ignore this step.

For other ARM Cortex-M microcontrollers, you only need to update the LENGTH of FLASH and RAM to your microcontroller.

If you're using nRF52840, ensure that you have Adafruit_nRF52_Bootloader flashed to your board. Most nice!nano compatible boards have it already. As long as you can open a USB drive for your board and update uf2 firmware by dragging and dropping, you're all set.

You can either checkout your microcontroller's datasheet or an existing Rust project of your microcontroller for it.

Add your own layout(vial.json)

The layout should be consistent with the default keymap set in keyboard.toml

The next step is to add your own keymap layout for your firmware. RMK supports vial app, an open-source cross-platform(windows/macos/linux/web) keyboard configurator. So the vial like keymap definition has to be imported to the firmware project.

Fortunately, RMK does most of the heavy things for you, all you need to do is to create your own keymap definition and convert it to vial.json following vial's doc here, and place it at the root of the firmware project, replacing the default one. RMK will do all the rest for you.

(Optional) Update compilation target

For stm32 microcontrollers, the compilation target varies according to the series. If there's no project template for your specific stm32 model, a common template will be used. An extra step for the common template is to update .cargo/config.toml, change the project's default target:

[build]
# Pick ONE of these default compilation targets
# target = "thumbv6m-none-eabi"        # Cortex-M0 and Cortex-M0+
# target = "thumbv7m-none-eabi"        # Cortex-M3
# target = "thumbv7em-none-eabi"       # Cortex-M4 and Cortex-M7 (no FPU)
target = "thumbv7em-none-eabihf"     # Cortex-M4F and Cortex-M7F (with FPU)
# target = "thumbv8m.base-none-eabi"   # Cortex-M23
# target = "thumbv8m.main-none-eabi"   # Cortex-M33 (no FPU)
# target = "thumbv8m.main-none-eabihf" # Cortex-M33 (with FPU)

It's also welcome to submit and share your project template, please open an issue with your project attached.

Compile the firmware

Compiling the firmware is easy, just run

cargo build --release

If you've done all the previous steps correctly, you can find your compiled firmware at target/<your_target>/release folder, whose name is your project's name or the name set in Cargo.toml's [[bin]] section.

The firmware generated by Rust has no extension, which is actually an ELF file.

If you encountered any problems when compiling the firmware, please report it here.

Compile uf2 firmware

By default, Rust firmware is an ELF file, so we have to do some extra steps converting it to uf2 format.

RMK uses cargo-make to automate the uf2 firmware generation.

Then you should make sure the chip family argument(aka argument after --family) in Makefile.toml is correct. You can get your chip's family here.

That's all you need to set up. The final step is to run

cargo make uf2 --release

to generate your uf2 firmware.

Flash the firmware

The last step is to flash compiled firmware to your microcontroller. RMK supports flashing the firmware via uf2 bootloader or debug probe.

Use uf2 bootloader

Flashing using uf2 bootloader is easy: set your board to bootloader mode, then a USB drive should appear in your computer. Copy the uf2 firmware to the USB drive, and that's it!

If you're using macOS, an error might appear, you can safely ignore it.

Tips for nRF52840

For nRF52840, you need to check whether your have a UF2 bootloader flashed to your board. If you can enter bootloader mode, there will be an USB drive shown in your computer. If there's INFO_UF2.TXT in the USB drive, you have it! Then check the memory.x, ensure that the flash origin starts with 0x00001000:

MEMORY
{
  /* NOTE 1 K = 1 KiB = 1024 bytes */
  /* These values correspond to the nRF52840 WITH Adafruit nRF52 bootloader */
  FLASH : ORIGIN = 0x00001000, LENGTH = 1020K
  RAM : ORIGIN = 0x20000008, LENGTH = 255K

  /* These values correspond to the nRF52840 */
  /* FLASH : ORIGIN = 0x00000000, LENGTH = 1024K */
  /* RAM : ORIGIN = 0x20000000, LENGTH = 256K */
}

If you have a debug probe and don't want to use the bootloader, use the following memory.x config:

{
  /* NOTE 1 K = 1 KiB = 1024 bytes */
  /* These values correspond to the nRF52840 */
  FLASH : ORIGIN = 0x00000000, LENGTH = 1024K
  RAM : ORIGIN = 0x20000000, LENGTH = 256K
}

You can edit your memory.x to choose correct value for your bootloader.

Use debug probe

If you have a debug probe like daplink, jlink or stlink(stm32 only), things become much easier: connect it with your board and host, make sure you have installed probe-rs, then just run

cargo run --release

Then the command configured in .cargo/config.toml will be executed. The firmware will be flashed to your microcontroller and run automatically, yay!

For more configurations of RMK, you can check out feature documentations on the left.

FAQ

My matrix is row2col, the matrix doesn't work

RMK enables col2row as the default feature. To use the row2col matrix, you have to change your Cargo.toml, adds default-features = false to RMK crate, disabling the col2row feature.

# Cargo.toml
rmk = { version = "0.5", default-features = false, features = ["nrf52840_ble", "async_matrix"] }

If you're using the cloud compilation, you have to update your keyboard.toml, add row2col = true under the [matrix] section or [split.central.matrix] section:

# keyboard.toml
[matrix]
row2col = true

# Or
[split.central.matrix]
row2col = true

Where is my built firmware?

By default, the built firmware is at target/<TARGET>/<MODE> folder, where <TARGET> is your microcontroller's target and <MODE> is debug or release, depending on your build mode.

The firmware's name is your project name in Cargo.toml. It's actually an elf file, but without file extension.

I want hex/bin/uf2 file, how can I get it?

By default, Rust compiler generates elf file in target folder. There're a little extra steps for generating hex, bin or uf2 file.

  • hex/bin: To generate hex/bin file, you need cargo-binutils. You can use

    cargo install cargo-binutils
    rustup component add llvm-tools
    

    to install it. Then, you can use the following command to generate hex or bin firmware:

    # Generate .bin file
    cargo objcopy --release -- -O binary rmk.bin
    # Generate .hex file
    cargo objcopy --release -- -O ihex rmk.hex
    
  • uf2: RMK provides cargo-make config for all examples to generate uf2 file automatically. Check Makefile.toml files in the example folders. The following command can be used to generate uf2 firmware:

    # Install cargo-make
    cargo install --force cargo-make
    
    # Generate uf2
    cargo make uf2 --release
    

I changed keymap in keyboard.toml, but the keyboard is not updated

RMK assumes that users change the keymap using vial. So reflashing the firmware won't change the keymap by default. For testing senario, RMK provides a config clear_storage under [storage] section, you can enable it to clear the storage when the keyboard boots.

[storage]
# Set `clear_storage` to true to clear all the stored info when the keyboard boots
clear_storage = true

Note that the storage will be clear EVERYTIME you reboot the keyboard.

I can see a RMK Start log, but nothing else

First you need to check the RCC config of your board, make sure that the USB's clock is enabled and set to 48MHZ. For example, if you're using stm32f1, you can set the RCC as the following:

#![allow(unused)]
fn main() {
// If you're using a keyboard.toml
#[rmk_keyboard]
mod keyboard {
    use embassy_stm32::{time::Hertz, Config};

    #[Override(chip_config)]
    fn config() -> Config {
        let mut config = Config::default();
        config.rcc.hse = Some(Hse {
            freq: Hertz(8_000_000),
            // Oscillator for bluepill, Bypass for nucleos.
            mode: HseMode::Oscillator,
        });
        config.rcc.pll = Some(Pll {
            src: PllSource::HSE,
            prediv: PllPreDiv::DIV1,
            mul: PllMul::MUL9,
        });
        config.rcc.sys = Sysclk::PLL1_P;
        config.rcc.ahb_pre = AHBPrescaler::DIV1;
        config.rcc.apb1_pre = APBPrescaler::DIV2;
        config.rcc.apb2_pre = APBPrescaler::DIV1; 
        config
    }
}
}

If the keyboard still doesn't work, enabling full logging trace at .cargo/config.toml:

[env]
DEFMT_LOG = "trace"

run cargo clean and then cargo run --release. Open an issue with the detailed logs.

rust-lld: error: section will not fit in region 'FLASH': overflowed by x bytes

This is because your MCU's flash is too small. Try building in release mode: cargo build --release. If the error still there, follow our binary size optimization doc to reduce your code size.

I see ERROR: Storage is full error in the log

By default, RMK uses only 2 sectors of your microcontroller's internal flash. You may get the following error if 2 sectors is not big enough to store all your keymaps:

ERROR Storage is full
└─ rmk::storage::print_sequential_storage_err @ /Users/haobogu/Projects/keyboard/rmk/rmk/src/storage.rs:577 
ERROR Got none when reading keymap from storage at (layer,col,row)=(1,5,8)
└─ rmk::storage::{impl#2}::read_keymap::{async_fn#0} @ /Users/haobogu/Projects/keyboard/rmk/rmk/src/storage.rs:460 
ERROR Keymap reading aborted!
└─ rmk::keymap::{impl#0}::new_from_storage::{async_fn#0} @ /Users/haobogu/Projects/keyboard/rmk/rmk/src/keymap.rs:38  

If you have more sectors available in your internal flash, you can increase num_sectors in [storage] section of your keyboard.toml, or change storage_config in your RmkConfig if you're using Rust API.

panicked at embassy-executor: task arena is full.

The current embassy requires manually setting of the task arena size. By default, RMK set's it to 32768 in all examples:

# Cargo.toml
embassy-executor = { version = "0.7", features = [
    "defmt",
    "arch-cortex-m",
    "task-arena-size-32768",
    "executor-thread",
] }

If you got ERROR panicked at 'embassy-executor: task arena is full. error after flashing to your MCU, that means that you should increase your embassy's task arena. Embassy has a series cargo features to do this, for example, changing task arena size to 65536:

# Cargo.toml
embassy-executor = { version = "0.7", features = [
    "defmt",
    "arch-cortex-m",
-   "task-arena-size-32768",
+   "task-arena-size-65536",
    "executor-thread",
] }

In the latest git version of embassy, task arena size could be calculated automatically, but it requires nightly version of Rust.

If you're comfortable with nightly Rust, you can enable nightly feature of embassy-executor and remove task-arena-size-* feature.

RMK breaks my bootloader

By default RMK uses last 2 sectors as the storage. If your bootloader is placed there too, RMK will erase it. To avoid it, you can change start_addr in [storage] section of your keyboard.toml, or change storage_config in your RmkConfig if you're using Rust API.

It's Honk.

Real world examples

This pages contains real world examples of RMK keyboards.

rmk-ble-keyboard

A BLE/USB dual-mode GH60 keyboard using Ebyte's E73 nRF52840 module.

rmk-ble-keyboard

dactyl-lynx-rmk

Show your keyboard!

If you're using RMK to build your keyboard, feel free to open a PR adding your project to this page!

Configuration

RMK provides an easy and accessible way to set up the keyboard with a toml config file, even without Rust code!

Usage

A toml file named keyboard.toml is used as the configuration file of RMK. The following is the spec of toml if you're unfamiliar with toml:

RMK provides a proc-macro to load the keyboard.toml at your projects root: #[rmk_keyboard], add it to your main.rs like:

#![allow(unused)]
fn main() {
use rmk::macros::rmk_keyboard;

#[rmk_keyboard]
mod my_keyboard {}
}

And, that's it! The #[rmk_keyboard] macro would load your keyboard.toml config and create everything that's needed for creating a RMK keyboard instance.

If you don't want any other customizations beyond the keyboard.toml, #[rmk_keyboard] macro will just work. For the examples, please check the example/use_config folder.

What's in the config file?

The config file contains almost EVERYTHING to customize a keyboard. For the full reference of keyboard.toml, please refer to this doc. Also, we have pre-defined default configurations for chips, at rmk-macro/src/default_config folder. We're going to add default configurations for more chips, contributions are welcome!

The following are the available tables and related documentaion available in keyboard.toml:

  • Keyboard and matrix: basic information and physical key matrix definition of the keyboard
  • Layout: layout and default keymap configuration of the keyboard
  • Split keyboard: split keyboard configuration
  • Storage: configuration for storage, which is used for on-board config and keymap
  • Behavior: configuration for advanced keyboard behaviors, such as one-shot key, tri-layer, tap-hold(including HRM mode), etc.
  • Input device: configuration for input devices, such as rotary encoder, joystick, etc.
  • Wireless/Bluetooth: configuration for wireless/bluetooth
  • Light: configuration for lights
  • RMK config: internal configurations of RMK, such as length of communication channels, number of allowed macros, etc
  • Appendix: full spec and references of the keyboard.toml

TODOs:

  • read vial.json and check whether vial.json is consist of keyboard.toml

Keyboard and matrix

[keyboard]

[keyboard] section contains basic information of the keyboard, such as keyboard's name, chip, etc:

[keyboard]
name = "RMK Keyboard"
vendor_id = 0x4c4b
product_id = 0x4643
manufacturer = "RMK"
chip = "stm32h7b0vb"
# If your chip doesn't have a functional USB peripheral, for example, nRF52832/esp32c3(esp32c3 has only USB serial, not full functional USB), set `usb_enable` to false
usb_enable = true

[matrix]

[matrix] section defines the physical key matrix IO information of the keyboard, aka input/output pins.

For split keyboard, this section should be just ignored, the matrix IO pins for split keyboard are defined in `[split]` section.

Key matrix

For the normal key matrix, in order to identify the IO pins take a look at your keyboard's schematic: The pin going to the diode (called anode) is an output pin, the pin coming out (called cathode) is an input pin:

output_pin =>   >|   => input_pin
                 ↑
              diode(be aware of it's direction)
Per default RMK assumes that your pins are col2row, meaning that the output pins (anodes) represent the columns and the input pins (cathodes) represent the rows. If your schemata shows the opposite you need to change the configuration to `row2col`

IO pins are represented with an array of string, the string value should be the GPIO peripheral name of the chip. For example, if you're using stm32h750xb, you can go to https://docs.embassy.dev/embassy-stm32/git/stm32h750xb/peripherals/index.html to get the valid GPIO peripheral name:

gpio_peripheral_name

The GPIO peripheral name varies for different chips. For example, RP2040 has PIN_0, nRF52840 has P0_00 and stm32 has PA0. So it's recommended to check the embassy's doc for your chip to get the valid GPIO name first.

Here is an example toml of [matrix] section for stm32:

[matrix]
# Input and output pins are mandatory
input_pins = ["PD4", "PD5", "PD6", "PD3"]
output_pins = ["PD7", "PD8", "PD9"]
# WARNING: Currently row2col/col2row is set in RMK's feature gate, row2col config here is valid ONLY when you're using cloud compilation
# row2col = true

Direct pins

If your keys are directly connected to the microcontroller pins, set matrix_type to direct_pin. (The default value for matrix_type is normal)

direct_pins is a two-dimensional array that represents the physical layout of your keys.

If your pin requires a pull-up resistor and the button press pulls the pin low, set direct_pin_low_active to true. Conversely, set it to false if your pin requires a pull-down resistor and the button press pulls the pin high.

Here is an example for rp2040.

matrix_type = "direct_pin"
direct_pins = [
    ["PIN_0", "PIN_1", "PIN_2"],
    ["PIN_3", "_", "PIN_5"]
]
# `direct_pin_low_active` is optional. Default to `true`.
direct_pin_low_active = true

Layout

[layout]

[matrix] defines the physical key matrix on your board, while [layout] section contains the layout and the default keymap for the keyboard:

[layout]
rows = 5
cols = 4
layers = 3
matrix_map = """
    ... the mapping between the "electronic matrix" of your keyboard 
        and your key map configuration is described here ...
"""

The matrix_map is a string built from (row, col) coordinate pairs, listed in the same order as you want to define your keys in your key map. The (row, col) coordinates are using zero based indexing and referring to the position in the "electronic matrix" of your keyboard. As you can see in matrix configuration, even the direct pin based keyboards are represented with a matrix. In case of split keyboards, the positions refer to the position in the "big unified matrix" of all split parts. With the help of this matrix map, the configuration of non-regular key matrices can be intuitively arranged in your key maps. (Triple quote mark """ is used to limit multi-line strings

# ┌───┬───┬───┬───┐
# │NUM│ / │ * │ - │ <-- row 0, col 0..4
# ├───┼───┼───┼───┤
# │ 7 │ 8 │ 9 │   │
# ├───┼───┼───┤ + │
# │ 4 │ 5 │ 6 │   │
# ├───┼───┼───┼───┤
# │ 1 │ 2 │ 3 │ E │
# ├───┴───┼───┤ N │
# │   0   │ . │ T │
# └───────┴───┴───┘
[layout]
rows = 5
cols = 4
layers = 3
matrix_map = """
(0,0) (0,1) (0,2) (0,3)
(1,0) (1,1) (1,2) (1,3)
(2,0) (2,1) (2,2)
(3,0) (3,1) (3,2) (3,3)
   (4,0)    (4,1) 
"""

Once the layout is defined, the key mapping can be described for each layer:

# layer 0 (default):
[[layer]]
name = "base_layer" #optional name for the layer
keys = """
NumLock KpSlash KpAsterisk KpMinus
Kp7     Kp8     Kp9        KpPlus
Kp4     Kp5     Kp6
Kp1     Kp2     Kp3        Enter
    Kp0         KpDot
"""

# layer 1:
[[layer]]
name = "mouse_navigation" #optional name for the layer
keys = """
TO(base_layer)   @my_cut    @my_copy         @my_paste
MouseBtn1        MouseUp    MouseBtn2        MouseWheelUp
MouseLeft        MouseBtn4  MouseRight
MouseWheelLeft   MouseDown  MouseWheelRight  MouseWheelDown
       MouseBtn1            MouseBtn12
"""

The number and order of entries on each defined layers must be identical with the number and order of entries in matrix_map. White spaces, line breaks are free to vary, but its worth to keep a consistent arrangement with the real keyboard.

If the number of defined layers is smaller than what was defined in `layout.layers`, RMK will fill empty layers automatically (so you can configure them freely in Vial). But the empty layers still consumes flash and RAM, so if you don't have a enough space for them, it's not recommended to use a big layer count.

In each layer.keys, the keys are bound to various key actions. Due to the limitation of toml file, this is done in a string. RMK parses the string and fill the to actual keymap initializer, like what's in keymap.rs

The layer.keys string should follow several rules:

  1. For a simple keycode(aka keys in RMK's KeyCode enum), just fill its name.

    For example, if you set a keycode Backspace, it will be turned to KeyCode::Backspace. So you have to ensure that the keycode string is valid, or RMK wouldn't compile! However, to make things easier a number of alternative key names were added and also case-insensitive search is used to find the valid KeyCode.

    For simple keycodes with modifiers active, you can use WM(key, modifier) to create a keypress with modifier action. Modifiers can be chained together like LShift | RGui to have multiple modifiers active.

    You may use aliases, prefixed with @, like @my_copy in the above example. The alias names are case sensitive. The definition of aliases is described below.

    You may use layer names instead of layer numbers, like TO(base_layer) in the above example.

    Please note that layer name if used like this, may not contain white spaces and may not be a number. Layer names are case sensitive.
  2. For no-key (KeyAction::No), use No

  3. For transparent key (KeyAction::Transparent), use _ or __ (you can put any number of _)

  4. RMK supports many advanced layer operations:

    1. Use DF(n) to create a switch default layer action, n is the layer number
    2. Use MO(n) to create a layer activate action, n is the layer number
    3. Use LM(n, modifier) to create layer activate with modifier action. The modifier can be chained in the same way as WM
    4. Use LT(n, key) to create a layer activate action or tap key(tap/hold). The key here is the RMK KeyCode
    5. Use OSL(n) to create a one-shot layer action, n is the layer number
    6. Use OSM(modifier) to create a one-shot modifier action. The modifier can be chained in the same way as WM
    7. Use TT(n) to create a layer activate or tap toggle action, n is the layer number
    8. Use TG(n) to create a layer toggle action, n is the layer number
    9. Use TO(n) to create a layer toggle only action (activate layer n and deactivate all other layers), n is the layer number

The definitions of those operations are same with QMK, you can found here. If you want other actions, please fire an issue.

  1. For modifier-tap-hold, use MT(key, modifier) where the modifier can be a chain like explained on point 1. For example for a Home row modifier config you can use MT(F, LShift)

  2. For generic key tap-hold, use TH(key-tap, key-hold)

  3. For shifted key, use SHIFTED(key)

[aliases]

[aliases] section contains a table of user defined names and an associated replacement string, which can be used in the layer.keys:

# here are the aliases for the example above
[aliases]
my_cut = "WM(X, LCtrl)"
my_copy = "WM(C, LCtrl)"
my_paste = "WM(V, LCtrl)"
Please note that alias names may not contain white spaces and they are case sensitive.

Split keyboard

See Split Keyboard section

Storage

[storage]

[storage] section defines storage related configs. Storage feature is required to persist keymap data, it's strongly recommended to make it enabled(and it's enabled by default!). RMK will automatically use the last two section of chip's internal flash as the pre-served storage space. For some chips, there's also predefined default configuration, such as nRF52840. If you don't want to change the default setting, just ignore this section.

[storage]
# Storage feature is enabled by default
enabled = true
# Start address of local storage, MUST BE start of a sector.
# If start_addr is set to 0(this is the default value), the last `num_sectors` sectors will be used.
start_addr = 0x00000000
# How many sectors are used for storage, the default value is 2
num_sectors = 2

Behavior

[behavior]

[behavior] section contains configuration for how different keyboard actions should behave:

[behavior]
tri_layer = { uppper = 1, lower = 2, adjust = 3 }  
one_shot = { timeout = "1s" }

Tri Layer

Tri Layer works by enabling a layer (called adjust) when other two layers (upper and lower) are both enabled.

You can enable Tri Layer by specifying the upper, lower and adjust layers in the tri_layer sub-table:

[behavior.tri_layer]
uppper = 1
lower = 2
adjust = 3

In this example, when both layers 1 (upper) and 2 (lower) are active, layer 3 (adjust) will also be enabled.

Note that "#layer_name" could also be used in place of layer numbers.

Tap Hold

In the tap_hold sub-table, you can configure the following parameters:

  • enable_hrm: Enables or disables HRM (Home Row Mod) mode. When enabled, the prior_idle_time setting becomes functional. Defaults to false.
  • prior_idle_time: If the previous non-modifier key is released within this period before pressing the current tap-hold key, the tap action for the tap-hold behavior will be triggered. This parameter is effective only when enable_hrm is set to true. Defaults to 120ms.
  • hold_timeout: Defines the duration a tap-hold key must be pressed to determine hold behavior. If tap-hold key is released within this time, the key is recognized as a "tap". Holding it beyond this duration triggers the "hold" action. Defaults to 250ms.
  • post_wait_time: Adds an additional delay after releasing a tap-hold key to check if any keys pressed during the hold_timeout are released. This helps accommodate fast typing scenarios where some keys may not be fully released during a hold. Defaults to 50ms

The following are the typical configurations:

[behavior]
# Enable HRM 
tap_hold = { enable_hrm = true, prior_idle_time = "120ms", hold_timeout = "250ms", post_wait_time = "50ms"}
# Disable HRM, you can safely ignore any fields if you don't want to change them
tap_hold = { enable_hrm = false, hold_timeout = "200ms" }

One Shot

In the one_shot sub-table you can define how long OSM or OSL will wait before releasing the modifier/layer with the timeout option, default is one second. timeout is a string with a suffix of either "s" or "ms".

[behavior.one_shot]
timeout = "5s"

Combo

In the combo sub-table, you can configure the keyboard's combo key functionality. Combo allows you to define a group of keys that, when pressed simultaneously, will trigger a specific output action.

Combo configuration includes the following parameters:

  • timeout: Defines the maximum time window for pressing all combo keys. If the time exceeds this, the combo key will not be triggered. The format is a string, which can be milliseconds (e.g. "200ms") or seconds (e.g. "1s").
  • combos: An array containing all defined combos. Each combo configuration is an object containing the following attributes:
    • actions: An array of strings defining the keys that need to be pressed simultaneously to trigger the combo action.
    • output: A string defining the output action to be triggered when all keys in actions are pressed simultaneously.
    • layer: An optional parameter, a number, specifying which layer the combo is valid on. If not specified, the combo is valid on all layers.

Here is an example of combo configuration:

[behavior.combo]
timeout = "150ms"
combos = [
  # Press J and K keys simultaneously to output Escape key
  { actions = ["J", "K"], output = "Escape" },
  # Press F and D keys simultaneously to output Tab key, but only valid on layer 0
  { actions = ["F", "D"], output = "Tab", layer = 0 },
  # Three-key combo, press A, S, and D keys to switch to layer 2
  { actions = ["A", "S", "D"], output = "TO(2)" }
]

Fork

In the fork sub-table, you can configure the keyboard's state based key fork functionality. Forks allows you to define a trigger key and condition dependent possible replacement keys. When the trigger key is pressed, the condition is checked by the following rule: If any of the match_any states are active AND none of the match_none states active, the trigger key will be replaced with positive_output, otherwise with the negative_output. By default the modifiers listed in match_any will be suppressed (even the one-shot modifiers) for the time the replacement key action is executed. However, with kept_modifiers some of them can be kept instead of automatic suppression.

Fork configuration includes the following parameters:

  • forks: An array containing all defined forks. Each fork configuration is an object containing the following attributes:
    • trigger: Defines the triggering key.
    • negative_output: A string defining the output action to be triggered when the conditions are not met
    • positive_output: A string defining the output action to be triggered when the conditions are met
    • match_any: A strings defining a combination of modifier keys, lock leds, mouse buttons (optional)
    • match_none: A strings defining a combination of modifier keys, lock leds, mouse buttons (optional)
    • kept_modifiers: A strings defining a combination of modifier keys, which should not be 'suppressed' form the keyboard state for the time the replacement action is executed. (optional)
    • bindable: Enables the evaluation of not yet triggered forks on the output of this fork to further manipulate the output. Advanced use cases can be solved using this option. (optional)

For match_any, match_none the legal values are listed below (many values may be combined with "|"):

  • LShift, LCtrl, LAlt, LGui, RShift, RCtrl, RAlt, RGui (these are including the effect of explicitly held and one-shot modifiers too)
  • CapsLock, ScrollLock, NumLock, Compose, Kana
  • MouseBtn1 .. MouseBtn8

Here is a sample of fork configuration with random examples:

[behavior.fork]
forks = [
  # Shift + '.' output ':' key
  { trigger = "Dot", negative_output = "Dot", positive_output = "WM(Semicolon, LShift)", match_any = "LShift|RShift" },

  # Shift + ',' output ';' key but only if no Alt is pressed
  { trigger = "Comma", negative_output = "Comma", positive_output = "Semicolon", match_any = "LShift|RShift", match_none = "LAlt|RAlt" },  
  
  # left bracket outputs by default '{', with shifts pressed outputs '['  
  { trigger = "LeftBracket", negative_output = "WM(LeftBracket, LShift)", positive_output = "LeftBracket", match_any = "LShift|RShift" },

  # Flip the effect of shift on 'x'/'X'
  { trigger = "X", negative_output = "WM(X, LShift)", positive_output = "X", match_any = "LShift|RShift" },

  # F24 usually outputs 'a', except when Left Shift or Ctrl pressed, in that case triggers a macro 
  { trigger = "F24", negative_output = "A", positive_output = "Macro1", match_any = "LShift|LCtrl" },

  # Swap Z and Y keys if MouseBtn1 is pressed (on the keyboard) (Note that these must not be bindable to avoid infinite fork loops!) 
  { trigger = "Y", negative_output = "Y", positive_output = "Z", match_any = "MouseBtn1", bindable = false },
  { trigger = "Z", negative_output = "Z", positive_output = "Y", match_any = "MouseBtn1", bindable = false },

  # Shift + Backspace output Delete key (inside a layer tap/hold)
  { trigger = "LT(2, Backspace)", negative_output = "LT(2, Backspace)", positive_output = "LT(2, Delete)", match_any = "LShift|RShift" },

  # Ctrl + play/pause will send next track. MediaPlayPause -> MediaNextTrack
  # Ctrl + Shift + play/pause will send previous track. MediaPlayPause -> MediaPrevTrack
  # Alt + play/pause will send volume up. MediaPlayPause -> AudioVolUp
  # Alt + Shift + play/pause will send volume down. MediaPlayPause -> AudioVolDown
  # Ctrl + Alt + play/pause will send brightness up. MediaPlayPause -> BrightnessUp
  # Ctrl + Alt + Shift + play/pause will send brightness down. MediaPlayPause -> BrightnessDown
  # ( Note that the trigger and immediate trigger keys of the fork chain could be 'virtual keys', 
  #   which will never output, like F23, but here multiple overrides demonstrated.)
    { trigger = "MediaPlayPause", negative_output = "MediaPlayPause", positive_output = "MediaNextTrack", match_any = "LCtrl|RCtrl", bindable = true },
  { trigger = "MediaNextTrack", negative_output = "MediaNextTrack", positive_output = "BrightnessUp", match_any = "LAlt|RAlt", bindable = true },
  { trigger = "BrightnessUp", negative_output = "BrightnessUp", positive_output = "BrightnessDown", match_any = "LShift|RShift", bindable = false },
  { trigger = "MediaNextTrack", negative_output = "MediaNextTrack", positive_output = "MediaPrevTrack", match_any = "LShift|RShift", match_none = "LAlt|RAlt", bindable = false},
  { trigger = "MediaPlayPause", negative_output = "MediaPlayPause", positive_output = "AudioVolUp", match_any = "LAlt|RAlt", match_none = "LCtrl|RCtrl", bindable = true },
  { trigger = "AudioVolUp", negative_output = "AudioVolUp", positive_output = "AudioVolDown", match_any = "LShift|RShift", match_none = "LCtrl|RCtrl", bindable = false } 
]

Please note that the processing of forks happen after combos and before others, so the trigger key must be the one listed in your keymap (or combo output). For example if LT(2, Backspace) is in your keymap, then trigger = "Backspace" will NOT work, you should "replace" the full key and use trigger = "LT(2, Backspace)" instead, like in the example above. You may want to include F24 or similar dummy keys in your keymap, and use them as trigger for your pre-configured forks, such as Shift/CapsLock dependent macros to enter unicode characters of your language.

Vial does not support fork configuration yet.

Input devices

All input devices are defined in the [input_device] table. Currently supported input device types include:

  • Rotary Encoder (encoder)
  • Joystick (joystick)

Rotary Encoder(not ready yet)

A rotary encoder is a common input device that can be used for volume control, page scrolling, and other functions. It can be defined in the configuration file as follows:

[[input_device.encoder]]
pin_a = "P0_30"
pin_b = "P0_31"

# Working mode of the encoder
# Available modes:
# - default: EC11 compatible mode, resolution = 1
# - e8h7: resolution = 2, direction reversed
# - resolution: custom resolution, requires specifying resolution and reverse parameters
phase = "default" 

# Resolution represents the number of pulses generated per detent
# For example: if your encoder has 30 detents and generates 15 pulses per 360-degree rotation, then resolution = 30/15 = 2
# The number of detents and pulses can be found in your encoder's datasheet
resolution = 2

# Whether to reverse the encoder direction
reverse = false

Multiple encoders can be added, and their indices are determined by the order of addition:

# Encoder 0
[[input_device.encoder]]
pin_a = "P0_01"
pin_b = "P0_02"
phase = "default" 

# Encoder 1
[[input_device.encoder]]
pin_a = "P0_03"
pin_b = "P0_04"
phase = "default" 

Joystick

A joystick is an analog input device that can be used for mouse control and other functions. Currently only NRF series chips are supported.

[[input_device.joystick]]
name = "default"
pin_x = "P0_31"
pin_y = "P0_29"
pin_z = "_"
transform = [[80, 0], [0, 80]]
bias = [29130, 29365]
resolution = 6

Parameters:

  • name: Unique name for the joystick. If you have multiple joysticks, they need different names
  • pin_x: Pin for X-axis
  • pin_y: Pin for Y-axis
  • pin_z: Pin for Z-axis
  • transform: Transformation matrix for the joystick
  • bias: Bias value for each axis
  • resolution: Resolution for each axis

Axis Configuration Note:

_ indicates that the axis does not exist.

_ is only allowed for:

  1. Both y and z axes are missing
  2. Only z axis is missing

For example: pin_x = "_" pin_y = "P0_29" pin_z = "P0_30" is not allowed

Working Principle

  1. Device reads values from each axis

  2. Adds bias value to each axis to make the value close to 0 when the joystick is released

  3. About the transform matrix:

    1. New x-axis value = (axis_x + bias[0]) / transform[0][0] + (axis_y + bias[1]) / transform[0][1] + (axis_z + bias[2]) / transform[0][2]
    2. New y-axis value = (axis_x + bias[0]) / transform[1][0] + (axis_y + bias[1]) / transform[1][1] + (axis_z + bias[2]) / transform[1][2]
    3. New z-axis value = (axis_x + bias[0]) / transform[2][0] + (axis_y + bias[1]) / transform[2][1] + (axis_z + bias[2]) / transform[2][2]

    If transform[new_axis][old_axis] is 0, that old axis value is ignored.

    Since the value range read by the ADC device is usually much larger than the mouse report range of -256~255, transform is designed as a divisor.

  4. Each axis value is adjusted to the largest integer multiple of resolution that is less than its original value to reduce noise from ADC device readings.

Quick Configuration Guide

  1. First set bias to 0, resolution to 1, and transform to [[1, 0, 0], [0, 1, 0], [0, 0, 1]] (matrix dimension depends on the number of axes)

  2. Find the optimal bias value:

    • Use a debug probe to find the output JoystickProcessor::generate_report: record = [axis_x, axis_y, axis_z] in debug information
    • Observe these values to find the bias value that makes each axis closest to 0 when the joystick is released
  3. If the mouse moves too fast, gradually increase the transform value until you find the right sensitivity

  4. If the mouse jitters, gradually increase the resolution value until the jitter disappears

Pointing Device(Draft, not implemented)

Pointing devices (such as touchpads) can be connected via I2C or SPI interface. Configuration examples:

[[input_device.pointing]]
interface = { i2c = { instance = "TWIM0", scl = "P0_27", sda = "P0_26" } }

or

[[input_device.pointing]]
interface = { spi = { instance = "SPIM0", sck = "P0_25", mosi = "P0_24", miso = "P0_23", cs = "P0_22", cpi = 1000 } }

Parameters:

I2C Configuration

  • instance: I2C instance name
  • scl: Clock pin
  • sda: Data pin

SPI Configuration

  • instance: SPI instance name
  • sck: Clock pin
  • mosi: Master Out Slave In pin
  • miso: Master In Slave Out pin
  • cs: Chip Select pin (optional)
  • cpi: Counts Per Inch (optional)

Wireless/Bluetooth

[ble]

To enable BLE, add enabled = true under the [ble] section.

There are several more configs for reading battery level and charging state, now they are available for nRF52840 only.

# Ble configuration
# To use the default configuration, ignore this section completely
[ble]
# Whether to enable BLE feature
enabled = true
# nRF52840's saadc pin for reading battery level, you can use a pin number or "vddh"
battery_adc_pin = "vddh"
# The voltage divider setting for saadc. 
# For example, nice!nano have 806 + 2M resistors, the saadc measures voltage on 2M resistor, so the two values should be set to 2000 and 2806
adc_divider_measured = 2000
adc_divider_total = 2806
# Pin that reads battery's charging state, `low-active` means the battery is charging when `charge_state.pin` is low
charge_state = { pin = "PIN_1", low_active = true }
# Output LED pin that blinks when the battery is low
charge_led= { pin = "PIN_2", low_active = true }

Light

[light]

[light] section defines lights of the keyboard, aka capslock, scrolllock and numslock. They are actually an input pin, so there are two fields available: pin and low_active.

pin field is just like IO pins in [matrix], low_active defines whether the light low-active or high-active(true means low-active).

You can safely ignore any of them, or the whole [light] section if you don't need them.

[light]
capslock = { pin = "PIN_0", low_active = true }
scrolllock = { pin = "PIN_1", low_active = true }
numslock= { pin = "PIN_2", low_active = true }

RMK Internal Configuration

The [rmk] section defines configuration parameters used inside RMK. These parameters affect the firmware's behavior, memory usage, and performance. If you don't need to change these parameters, you can ignore this section.

Configuration Example

[rmk]
# Mouse key interval (ms) - controls mouse movement speed
mouse_key_interval = 20
# Mouse wheel interval (ms) - controls scrolling speed
mouse_wheel_interval = 80
# Maximum number of combos keyboard can store
combo_max_num = 8
# Maximum number of keys pressed simultaneously in a combo
combo_max_length = 4
# Maximum number of forks for conditional key actions
fork_max_num = 8
# Maximum number of macros keyboard can store
macro_max_num = 8
# Macro space size in bytes for storing sequences
macro_space_size = 256
# Default debounce time in ms
debounce_time = 20
# Event channel size
event_channel_size = 16
# Report channel size
report_channel_size = 16
# Vial channel size
vial_channel_size = 4
# Flash channel size
flash_channel_size = 4
# The number of the split peripherals
split_peripherals_num = 1
# The size of the split message channel
split_message_channel_size = 4
# The number of available BLE profiles
ble_profiles_num = 3

Parameter Details

  • mouse_key_interval: Mouse key interval in milliseconds, default value is 20. This parameter controls the mouse movement speed; lower values result in faster movement.
  • mouse_wheel_interval: Mouse wheel interval in milliseconds, default value is 80. This parameter controls the scrolling speed; lower values result in faster scrolling.

Behavior Configuration

NOTE: Increasing the number of combos, forks and macros will increase memory usage.

  • combo_max_num: Maximum number of combos that the keyboard can store, default value is 8. This value must be between 0 and 256.
  • combo_max_length: Maximum number of keys that can be pressed simultaneously in a combo, default value is 4.
  • fork_max_num: Maximum number of forks for conditional key actions, default value is 8. This value must be between 0 and 256.
  • macro_max_num: Maximum number of macros that the keyboard can store, default value is 8.
  • macro_space_size: Space size in bytes for storing macro sequences, default value is 256.

Matrix Configuration

  • debounce_time: Default key debounce time in milliseconds, default value is 20.

Channel Configuration

In RMK there are several channels used for communication between tasks. The length of the channel can be adjusted. Larger channel size means more events can be buffered, but it will increase memory usage.

  • event_channel_size: The length of event channel, default value is 16.
  • report_channel_size: The length of report channel, default value is 16.
  • vial_channel_size: The length of vial channel, default value is 4.
  • flash_channel_size: The length of flash channel, default value is 4.

Split Keyboard Configuration

  • split_peripherals_num: The number of split peripherals, default value is 1.
  • split_message_channel_size: The length of the split message channel, default value is 4.

Wireless Configuration

  • ble_profiles_num: The number of available Bluetooth profiles, default value is 3. This parameter defines how many Bluetooth paired devices the keyboard can store.

Appendix

keyboard.toml

The following toml contains all available settings in keyboard.toml

# Basic info of the keyboard
[keyboard]
name = "RMK Keyboard" # Keyboard name
product_name = "RMK Keyboard" # Display name of this keyboard
vendor_id = 0x4c4b
product_id = 0x4643
manufacturer = "haobo"
serial_number = "vial:f64c2b3c:000001"
# The chip or existing board used in keyboard
# Either \"board\" or \"chip\" can be set, but not both
chip = "rp2040" 
board = "nice!nano_v2"
# USB is enabled by default for most chips
# Set to false if you don't want USB
usb_enable = true

# Set matrix IO for the board. This section is for non-split keyboard and is conflict with [split] section
[matrix]
# `matrix_type` is optional. Default is "normal"
matrix_type = "normal"
# Input and output pins
input_pins = ["PIN_6", "PIN_7", "PIN_8", "PIN_9"]
output_pins = ["PIN_19", "PIN_20", "PIN_21"]
# WARNING: Currently row2col/col2row is set in RMK's feature gate, row2col config here is valid ONLY when you're using cloud compilation

# Direct Pin Matrix is a Matrix of buttons connected directly to pins. It conflicts with the above.
matrix_type = "direct_pin"
direct_pins = [
    ["PIN_0", "PIN_1", "PIN_2"],
    ["PIN_3", "_", "PIN_5"]
]

# `direct_pin_low_active` is optional. Default is `true`.
# If your pin needs to be pulled up and the pin is pulled down when the button is turned on, please set it to true
# WARNING: If you use a normal matrix, it will be ineffective
direct_pin_low_active = true

# Layout info for the keyboard, this section is mandatory
[layout]
# Number of rows. For split keyboard, this is the total rows contains all splits
rows = 5
# Number of cols. For split keyboard, this is the total cols contains all splits
cols = 4
# Number of layers. Be careful, since large layer number takes more flash and RAM
layers = 3
# keypad example:
# ┌───┬───┬───┬───┐
# │NUM│ / │ * │ - │ <-- row 0, col 0..4
# ├───┼───┼───┼───┤
# │ 7 │ 8 │ 9 │   │
# ├───┼───┼───┤ + │
# │ 4 │ 5 │ 6 │   │
# ├───┼───┼───┼───┤
# │ 1 │ 2 │ 3 │ E │
# ├───┴───┼───┤ N │
# │   0   │ . │ T │
# └───────┴───┴───┘
matrix_map = """
(0,0) (0,1) (0,2) (0,3)
(1,0) (1,1) (1,2) (1,3)
(2,0) (2,1) (2,2)
(3,0) (3,1) (3,2) (3,3)
(4,0)       (4,1) 
"""

# here are the aliases for the example layer.keys below
[aliases]
my_cut = "WM(X, LCtrl)"
my_copy = "WM(C, LCtrl)"
my_paste = "WM(V, LCtrl)"

# Key map definitions per layer: 
# The number (and order) of entries on each layer should be 
# identical with the number (and order) of entries in `matrix_map`.
# Empty layers will be used to fill if the number of explicitly 
# defined layers is smaller then `layout.layers` setting

# layer 0 (default):
# (the number comes from the order of '[[layer]] entries' in the file)
[[layer]]
name = "base_layer" #optional name for the layer
keys = """
NumLock KpSlash KpAsterisk KpMinus
Kp7     Kp8     Kp9        KpPlus
Kp4     Kp5     Kp6
Kp1     Kp2     Kp3        Enter
    Kp0         KpDot
"""

# layer 1: 
[[layer]]
name = "mouse_navigation" #optional name for the layer
keys = """
TO(base_layer)   @MyCut     @MyCopy          @MyPaste
MouseBtn1        MouseUp    MouseBtn2        MouseWheelUp
MouseLeft        MouseBtn4  MouseRight
MouseWheelLeft   MouseDown  MouseWheelRight  MouseWheelDown
          MouseBtn1         MouseBtn12
"""

# Behavior configuration, if you don't want to customize anything, just ignore this section
[behavior]
# Tri Layer configuration
tri_layer = { uppper = 1, lower = 2, adjust = 3 }
# One Shot configuration
one_shot = { timeout = "1s" }

# Lighting configuration, if you don't have any light, just ignore this section.
[light]
# LED pins, capslock, scrolllock, numslock. You can safely ignore any of them if you don't have
capslock = { pin = "PIN_0", low_active = true }
scrolllock = { pin = "PIN_1", low_active = true }
numslock = { pin = "PIN_2", low_active = true }

# Storage configuration.
# To use the default configuration, ignore this section completely
[storage]
# Whether the storage is enabled
enabled = true
# The start address of storage
start_addr = 0x60000
# Number of sectors used for storage, >= 2
start_addr = 16

# Ble configuration
# To use the default configuration, ignore this section completely
[ble]
# Whether the ble is enabled
enabled = true
# BLE related pins, ignore any of them if you don't have
battery_adc_pin = "vddh"
# If the voltage divider is used for adc, you can use the following two values to define a voltage divider.
# For example, nice!nano have 806 + 2M resistors, the saadc measures voltage on 2M resistor, so the two values should be set to 2000 and 2806
# Measured resistance for input adc, it should be less than adc_divider_total
adc_divider_measured = 2000
# Total resistance of the full path for input adc
adc_divider_total = 2806
# Pin that reads battery's charging state, `low-active` means the battery is charging when `charge_state.pin` is low
# Input pin that indicates the charging state
charge_state = { pin = "PIN_1", low_active = true }
# Output LED pin that blinks when the battery is low
charge_led= { pin = "PIN_2", low_active = true }

# Split configuration
# This section is conflict with [split] section, you could only have either [matrix] or [split], but NOT BOTH
[split]
# Connection type of split, "serial" or "ble"
connection = "serial"

# Split central config
[split.central]
# Number of rows on central board
rows = 2
# Number of cols on central board
cols = 2
# Row offset of central matrix to the whole matrix
row_offset = 0
# Col offset of central matrix to the whole matrix
col_offset = 0
# If the connection type is "serial", the serial instances used on the central board are defined using "serial" field.
# It's a list of serial instances with a length equal to the number of splits.
# The order of the serial instances is important: the first serial instance on the central board
# communicates with the first split peripheral defined, and so on.
serial = [
    { instance = "UART0", tx_pin = "PIN_0", rx_pin = "PIN_1" },
    { instance = "UART1", tx_pin = "PIN_4", rx_pin = "PIN_5" },
    # For the RP2040 only, you can also use RMK's Programmable IO (PIO) UART serial port using either or both of the RP2040's two PIO blocks, PIO0 and PIO1, by enabling the RMK `rp2040_pio` feature gate in Cargo.toml.
    # The PIO serial port can be used in half-duplex mode using the same pin for RX/TX
    { instance = "PIO0", tx_pin = "PIN_6", rx_pin = "PIN_6" },
    # Or use the PIO serial port in full-duplex mode using different pins for RX/TX
    { instance = "PIO1", tx_pin = "PIN_7", rx_pin = "PIN_8" },
]
# If the connection type is "ble", we should have `ble_addr` to define the central's BLE static address
# This address should be a valid BLE random static address, see: https://academy.nordicsemi.com/courses/bluetooth-low-energy-fundamentals/lessons/lesson-2-bluetooth-le-advertising/topic/bluetooth-address/
ble_addr = [0x18, 0xe2, 0x21, 0x80, 0xc0, 0xc7]

[split.central.matrix]
matrix_type = "normal"
# Matrix IO definition on central board
input_pins = ["PIN_9", "PIN_11"]
output_pins = ["PIN_10", "PIN_12"]

# Configuration for the first split peripheral
# Note the double brackets [[ ]], which indicate that multiple split peripherals can be defined.
# The order of peripherals is important: it should match the order of the serial instances(if serial is used).
[[split.peripheral]]
# Number of rows on peripheral board
rows = 2
# Number of cols on peripheral board
cols = 1
# Row offset of peripheral matrix to the whole matrix
row_offset = 2
# Col offset of peripheral matrix to the whole matrix
col_offset = 2
# The serial instance used to communication with the central board, if the connection type is "serial"
serial = [{ instance = "UART0", tx_pin = "PIN_0", rx_pin = "PIN_1" }]
# The BLE random static address of the peripheral board
ble_addr = [0x7e, 0xfe, 0x73, 0x9e, 0x66, 0xe3]

[split.peripheral.matrix]
matrix_type = "normal"
# Matrix IO definition on peripheral board
input_pins = ["PIN_9", "PIN_11"]
output_pins = ["PIN_10"]

# More split peripherals(if you have)
[[split.peripheral]]
# The configuration is same with the first split peripheral
...
...
...

# Dependency config
[dependency]
# Whether to enable defmt, set to false for reducing binary size 
defmt_log = true

Available chip names

Available chip names in chip field:

  • rp2040
  • nrf52840
  • nrf52833
  • nrf52832
  • nrf52811
  • nrf52810
  • esp32c3
  • esp32c6
  • esp32s3
  • ALL stm32s supported by embassy-stm32 with USB

Available board names

Available board names in board field:

  • nice!nano
  • nice!nano_v2
  • XIAO BLE

If you want to add more built-in boards, feel free to open a PR!

Keymap configuration

RMK supports configuring the default keymap at the compile time. Keymap in RMK is a 3-D matrix of KeyAction, which represent the keyboard's action after you trigger a physical key. The 3 dimensions are the number of columns, rows and layers.

RMK provides both Rust code or config ways to set your default keymap.

Define default keymap in keyboard.toml

Please check this section in keyboard configuration doc.

Define default keymap in Rust source file

The default keymap could also be defined at a Rust source file, There are keymap.rss in example folder, such as this, which could be a good example of defining keymaps using Rust in RMK:

#![allow(unused)]
fn main() {
// https://github.com/HaoboGu/rmk/blob/main/examples/use_rust/nrf52840_ble/src/keymap.rs
use rmk::action::KeyAction;
use rmk::{a, k, layer, mo};
pub(crate) const COL: usize = 14;
pub(crate) const ROW: usize = 5;
pub(crate) const NUM_LAYER: usize = 2;

#[rustfmt::skip]
pub const fn get_default_keymap() -> [[[KeyAction; COL]; ROW]; NUM_LAYER] {
    [
        layer!([
            [k!(Grave), k!(Kc1), k!(Kc2), k!(Kc3), k!(Kc4), k!(Kc5), k!(Kc6), k!(Kc7), k!(Kc8), k!(Kc9), k!(Kc0), k!(Minus), k!(Equal), k!(Backspace)],
            [k!(Tab), k!(Q), k!(W), k!(E), k!(R), k!(T), k!(Y), k!(U), k!(I), k!(O), k!(P), k!(LeftBracket), k!(RightBracket), k!(Backslash)],
            [k!(Escape), k!(A), k!(S), k!(D), k!(F), k!(G), k!(H), k!(J), k!(K), k!(L), k!(Semicolon), k!(Quote), a!(No), k!(Enter)],
            [k!(LShift), k!(Z), k!(X), k!(C), k!(V), k!(B), k!(N), k!(M), k!(Comma), k!(Dot), k!(Slash), a!(No), a!(No), k!(RShift)],
            [k!(LCtrl), k!(LGui), k!(LAlt), a!(No), a!(No), k!(Space), a!(No), a!(No), a!(No), mo!(1), k!(RAlt), a!(No), k!(RGui), k!(RCtrl)]
        ]),
        layer!([
            [k!(Grave), k!(F1), k!(F2), k!(F3), k!(F4), k!(F5), k!(F6), k!(F7), k!(F8), k!(F9), k!(F10), k!(F11), k!(F12), k!(Delete)],
            [a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No)],
            [k!(CapsLock), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No)],
            [a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), k!(Up)],
            [a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), a!(No), k!(Left), a!(No), k!(Down), k!(Right)]
        ]),
    ]
}

}

First of all, the keyboard matrix's basic info(number of rows, cols and layers) is defined as consts:

#![allow(unused)]
fn main() {
pub(crate) const COL: usize = 14;
pub(crate) const ROW: usize = 5;
pub(crate) const NUM_LAYER: usize = 2;
}

Then, the keymap is defined as a static 3-D matrix of KeyAction:

#![allow(unused)]
fn main() {
// You should define a function that returns defualt keymap by yourself
pub const fn get_default_keymap() -> [[[KeyAction; COL]; ROW]; NUM_LAYER] {
    ...
}
}

A keymap in RMK is a 3-level hierarchy: layer - row - column. Each keymap is a slice of layers whose length is NUM_LAYER. Each layer is a slice of rows whose length is ROW, and each row is a slice of KeyActions whose length is COL.

RMK provides a bunch of macros which simplify the keymap definition a lot. You can check all available macros in RMK doc. For example, layer! macro is used to define a layer. k! macro is used to define a normal key in the keymap. If there is no actual key at a position, you can use a!(No) to represent KeyAction::No.

Special Keys

RMK maps all keys QMK does. However, at the time of writing, not all features are supported.

The following keys are supported (some further keys might work, but are not documented).

Repeat/Again key

Similar to QMK pressing this key repeats the last key pressed. Note that QMK binds this function to Kc_RepeatKey, while RMK binds it to Kc_Again. This ensures a better compatibility with Vial, which features the Again key as a dedicated key (unlike the RepeatKey, which doesn't exist in Vial). Although some old keyboards might have a key for Again, it is not used in modern operating systems anymore.

In QMK an AlternativeRepeatKey is supported. This functionalaty is not implemented in RMK.

Vial support

RMK uses vial as the default keymap editor. By using vial, you can change your keymapping at real-time, no more programming/flashing is needed.

To persistently save your keymap data, RMK will use the last two sectors of your microcontroller's internal flash. See storage. If you don't have enough flash for saving keymaps, changing in vial will lose after keyboard reboot.

Port vial

To use vial in RMK, a keyboard definition file named vial.json is necessary. Vial has a very detailed documentation for how to generate this JSON file: https://get.vial.today/docs/porting-to-via.html. One note for generating vial.json is that you have to use same layout definition of internal keymap of RMK, defined in src/keymap.rs or keyboard.toml.

In vial.json, you define the keyboard layout, specifying which keys are in which position. In src/keymap.rs or keyboard.toml, you define the keymap, meaning which symbol will be printed by the press of which button.

This is the default keymap, which you can change using the vial app (or the web app). Unless you set clear_storage = true (see [storage]), these changes will persist when you reset your keyboard.

After getting your vial.json, just place it at the root of RMK firmware project, and that's all. RMK will do all the rest work for you.

Wireless

RMK has built-in wireless(BLE) support for nRF52 series and ESP32. To use the wireless feature, you need to enable ble feature gate in your Cargo.toml:

rmk = { version = "0.4", features = [
    "nrf52840_ble", # Enable BLE feature for nRF52840
] }

RMK also provides ble examples, check nrf52840_ble, nrf52832_ble and esp32c3_ble.

Due to multiple targets are not supported by docs.rs right now, so API documentations are not there. Check examples for the usage. I'll add a separate doc site later.

Supported microcontrollers

The following is the list of available feature gates(aka supported BLE chips):

  • nrf52840_ble
  • nrf52833_ble
  • nrf52832_ble
  • nrf52810_ble
  • nrf52811_ble
  • esp32c3_ble
  • esp32c6_ble
  • esp32s3_ble

Flashing to your board

RMK can be flashed via a debug probe or USB. Follow the instruction in the examples/use_rust/nrf52840_ble/README.md

Nice!nano support

RMK has special support for nice!nano, a widely used board for building wireless keyboard.

nice!nano has a built-in bootloader, enables flashing a .uf2 format firmware via USB drive. examples/use_rust/nrf52840_ble/README.md provides instructions to convert RMK firmware to .uf2 format.

You can also refer to RMK user guide about the instructions.

Multiple-profile support

RMK supports at most 8 wireless profiles, profile 0 is activated by default. Vial user keycode can be configured to operate wireless profiles:

  • User0 - User7: switch to specific profile
  • User8: switch to next profile
  • User9: switch to previous profile
  • User10: clear current profile bond info
  • User11: switch default output between USB/BLE

Vial also provides a way to customize the displayed keycode, see customKeycodes in this example. If customKeycodes are configured, the User0 ~ User11 will be displayed as BT0, ..., Switch Output.

If you've connected a host for a profile, other devices would not be able to connect to this profile before doing manually clearing.

Low-power

RMK supports low-power mode by using utilizing embassy's low-power feature and Wait trait in embedded-hal-async. To enable low-power mode, add async_matrix feature to your Cargo.toml:

rmk = { version = "0.4", features = [
     "nrf52840_ble",
+    "async_matrix",
] }

If you're using nRF chips or rp2040, you're all set! You've already got your keyboard running in low-power mode.

For stm32, there's some limitations about Exti(see here):

EXTI is not built into Input itself because it needs to take ownership of the corresponding EXTI channel, which is a limited resource.

Pins PA5, PB5, PC5… all use EXTI channel 5, so you can’t use EXTI on, say, PA5 and PC5 at the same time.

There are a few more things that you have to do:

  1. Enable exti feature of your embassy-stm32 dependency

  2. Ensure that your input pins don't share same EXTI channel

  3. If you're using keyboard.toml, nothing more to do. The [rmk_keyboard] macro will check your Cargo.toml and do the work for you. But if you're using Rust code, you need to use ExtiInput as your input pins, and update generics type of RMK keyboard run:

#![allow(unused)]
fn main() {
    let pd9 = ExtiInput::new(p.PD9,  p.EXTI9, Pull::Down);
    let pd8 = ExtiInput::new(p.PD8,  p.EXTI8, Pull::Down);
    let pb13 = ExtiInput::new(p.PB13, p.EXTI13, Pull::Down);
    let pb12 = ExtiInput::new(p.PB12, p.EXTI12, Pull::Down);
    let input_pins = [pd9, pd8, pb13, pb12];

    let mut matrix = Matrix::<_, _, _, ROW, COL>::new(input_pins, output_pins, debouncer);
}

Storage

RMK uses the last 2 sectors of your microcontroller's flash by default. If you're using a bootloader like Adafruit_nRF52_Bootloader, which puts itself at the end of the flash, RMK will break it. Solving this by setting start_addr manually.

Storage feature is used by saving keymap edits to internal flash.

Storage configuration

If you're using the keyboard.toml, you can set the storage using the following config:

[storage]
# Storage feature is enabled by default
enabled = true
# Start address of local storage, MUST BE start of a sector.
# If start_addr is set to 0(this is the default value), the last `num_sectors` sectors will be used.
start_addr = 0x00000000
# How many sectors are used for storage, the default value is 2
num_sectors = 2
# Clear storage at keyboard boot. 
# Set it to true will reset the storage(including keymap, BLE bond info, etc.) at each reboot.
# This option is useful when testing the firmware.
clear_storage = false

You can also edit storage_config field in RmkConfig if you're using Rust API:

#![allow(unused)]
fn main() {
// https://github.com/HaoboGu/rmk/blob/main/examples/use_rust/nrf52832_ble/src/main.rs#L48

let storage_config = StorageConfig {
    start_addr: 0x70000,
    num_sectors: 2,
    ..Default::default()
};
let rmk_config = RmkConfig {
    usb_config: keyboard_usb_config,
    vial_config,
    storage_config,
    ..Default::default()
};

}

By default, RMK uses last 2 sectors of your microcontroller's internal flash as the storage space. So you have to ensure that you have enough flash space for storage feature. If there is not enough space, passing None is acceptable.

Split keyboard

RMK supports multi-split keyboard, which contains at least one central board and at most 8 peripheral boards. The host is connected to the central board via USB or BLE. All features in RMK are supported in split mode, such as VIAL via USB, layers, etc.

Example

See examples/use_rust/rp2040_split and for the wired split keyboard example using rp2040.

See examples/use_rust/rp2040_split_pio for the wired split keyboard example using rp2040 and PIO serial driver.

See examples/use_rust/nrf52840_ble_split for the wireless split keyboard example using nRF52840.

See examples/use_config/rp2040_split and for the keyboard.toml + wired split keyboard example using rp2040.

See examples/use_config/rp2040_split_pio and for the keyboard.toml + wired split keyboard example using rp2040 and PIO serial driver.

See examples/use_config/rp2040_split and for the keyboard.toml + wired split keyboard example using rp2040 and a direct pin matrix.

See examples/use_config/nrf52840_ble_split for the keyboard.toml + wireless split keyboard example using nRF52840.

See examples/use_config/nrf52840_ble_split for the keyboard.toml + wireless split keyboard example using nRF52840 and a direct pin matrix.

NOTE: for nrf52840_ble_split, add

[patch.crates-io]
nrf-softdevice = { version = "0.1.0", git = "https://github.com/embassy-rs/nrf-softdevice", rev = "b53991e"}

to your Cargo.toml if there's compilation error in nrf-softdevice dependency.

Define central and peripherals via keyboard.toml

You can also use the keyboard.toml to define a split keyboard.

All split related configurations are defined under [split] section. The following is an example using BLE:

[split]
# split connection type
connection = "ble"

# Split central
[split.central]
# Central's matrix definition and offsets
rows = 2
cols = 2
row_offset = 0
col_offset = 0

# Central's ble addr
ble_addr = [0x18, 0xe2, 0x21, 0x80, 0xc0, 0xc7]

# Central's matrix
[split.central.matrix]
matrix_type = "normal"
input_pins = ["P0_12", "P0_13"]
output_pins = ["P0_14", "P0_15"]

# Note there're TWO brackets, since the peripheral is a list
# Peripheral 0
[[split.peripheral]]
rows = 2
cols = 1
row_offset = 2
col_offset = 2
# Peripheral's ble addr
ble_addr = [0x7e, 0xfe, 0x73, 0x9e, 0x11, 0xe3]

# Peripheral 0's matrix definition
[split.peripheral.matrix]
matrix_type = "normal"
input_pins = ["P1_11", "P1_10"]
output_pins = ["P0_30"]

# Peripheral 1
[[split.peripheral]]
# Matrix definition
rows = 2
cols = 1
row_offset = 2
col_offset = 2
# Peripheral's ble addr
ble_addr = [0x7e, 0xfe, 0x71, 0x91, 0x11, 0xe3]

# Peripheral 1's matrix definition
[split.peripheral.matrix]
matrix_type = "normal"
input_pins = ["P1_11", "P1_10"]
output_pins = ["P0_30"]

When using split, the input/output pins defined in [matrix] section is not valid anymore. Instead, the input/output pins of split boards are defined in [split.central.matrix] and [split.peripheral.matrix]. The contents of the split matrix configuration is the same as for [matrix]. This means each peripheral and central keyboard also supports direct_pin.

The rows/cols in [layout] section is the total number of rows/cols of the whole keyboard. For each split(central and peripherals), rows/cols/row_offset/col_offset should be defined to indicate the current split's position in the whole keyboard's layout. Suppose we have a 2-row + 5-col split, the left(central) is 2*2, and the right(peripheral) is 2*3, the positions should be defined as:

[split.central]
rows = 2 # The number of rows in central
cols = 2 # The number of cols in central
row_offset = 0 # The row offset, for central(left) it's 0
col_offset = 0 # The col offset, for central(left) it's 0

[[split.peripheral]]
rows = 2 # The number of rows in the peripheral
cols = 3 # The number of cols in the peripheral
row_offset = 0 # The row offset of the peripheral, peripheral starts from row 0, so the offset is 0
col_offset = 2 # The col offset of the peripheral. Central has 2 cols, so the col_offset should be 2 for the peripheral

If you're using BLE, ble_addr is required for both central and peripheral. Each device needs a ble_addr.

If you're using serial, in [split.central] you need to defined a list of serial ports, the number of the list should be same with the number of the peripherals:

[split]
connection = "serial"

[split.central]
..
# Two serial ports used in central. The order matters.
serial = [
    # Serial port which is connected to peripheral 0.
    { instance = "UART0", tx_pin = "PIN_0", rx_pin = "PIN_1" },
    # Serial port which is connected to peripheral 1.
    { instance = "UART1", tx_pin = "PIN_4", rx_pin = "PIN_5" },
]

# Peripheral 0
[[split.peripheral]]
..
# Serial port used in peripheral 0, it's a list with only one serial port element.
serial = [{ instance = "UART0", tx_pin = "PIN_0", rx_pin = "PIN_1" }]

# Peripheral 1
[[split.peripheral]]
..
serial = [{ instance = "UART0", tx_pin = "PIN_0", rx_pin = "PIN_1" }]

If you're using the Programmable IO (PIO) serial port with an RP2040 chip, subsitute the UART serial port interface with the PIO block, e.g. PIO0:

[split]
connection = "serial"

[split.central]
..
serial = [
    # Half-duplex serial port using Programmable IO block PIO0
    { instance = "PIO0", tx_pin = "PIN_0", rx_pin = "PIN_0" },
]

[[split.peripheral]]
..
serial = [{ instance = "PIO0", tx_pin = "PIN_0", rx_pin = "PIN_0" }]

Define central and peripherals via Rust

In RMK, split keyboard's matrix are defined with row/col number and their offsets in the whole matrix.

Central

Matrix configuration on the split central is quite similar with the general keyboard, the only difference is for split central, central matrix's row/col number, and central matrix's offsets should be passed to the central matrix:

#![allow(unused)]
fn main() {
// Suppose that the central matrix is col2row
let mut matrix = CentralMatrix::<
    _,
    _,
    _,
    0, // ROW OFFSET 
    0, // COL OFFSET
    4, // ROW 
    7, // COL
>::new(input_pins, output_pins, debouncer);
}

On the central, you should also run the peripheral manager for each peripheral. This task monitors the peripheral key changes and forwards them to central core keyboard task

#![allow(unused)]
fn main() {
// nRF52840 split central, arguments might be different for other microcontrollers, check the API docs for the detail.
run_peripheral_manager<
    2, // PERIPHERAL_ROW
    1, // PERIPHERAL_COL
    2, // PERIPHERAL_ROW_OFFSET
    2, // PERIPHERAL_COL_OFFSET
    _,
  >(peripheral_id, peripheral_addr, &stack)
}

Peripheral

Running split peripheral is simplier. For peripheral, we don't need to specify peripheral matrix's offsets(we've done it in central!). So, the split peripheral API is like:

#![allow(unused)]
fn main() {
// Use normal matrix on the peripheral
let mut matrix = Matrix::<_, _, _, 4, 7>::new(input_pins, output_pins, debouncer);

// nRF52840 split peripheral, arguments might be different for other microcontrollers, check the API docs for the detail.
run_rmk_split_peripheral(central_addr, &stack),
}

where 2,2 are the size of peripheral's matrix.

Communication

RMK supports both wired and wireless communication.

Currently, the communication type indicates that how split central communicates with split peripherals. How the central talks with the host depends only on the central.

  • For communication over BLE: the central talks with the host via BLE or USB, depends on whether the USB cable is connected
  • For communication over serial: the central can only use USB to talk with the host

Wired split

RMK uses embedded-io-async as the abstract layer of wired communication. Any device that implements embedded-io-async::Read and embedded-io-async::Write traits can be used as RMK split central/peripheral. The most common implementations of those traits are serial ports(UART/USART), such as embassy_rp::uart::BufferedUart and embassy_stm32::usart::BufferedUart. That unlocks many possibilities of RMK's split keyboard. For example, using different chips for central/peripheral is easy in RMK.

For hardwire connection, the TRRS cable is widely used in split keyboards to connect central and peripherals. It's also compatible with UART/USART, that means RMK can be used in most existing opensource serial based split keyboard hardwares.

For keyboards connected using only a single wire, e.g. a 3-pole TRS cable, for the RP2040 only RMK implements a half-duplex UART serial port, rmk::split::rp::uart::BufferedUart, using one or both of the Programmable IO (PIO) blocks available on the RP2040 chip. The PIO serial port also supports full-duplex over two wires, and can be used when the central/peripheral connection does not use the pins connected to the chip's standard UART ports.

To use the the PIO UART driver feature, you need to enable the rp2040_pio feature gate in your Cargo.toml:

rmk = { version = "0.4", features = [
    "split",
    "rp2040_pio", # Enable PIO UART driver for rp2040
] }

Wireless split

RMK supports BLE wireless split on only nRF chips right now. The BLE random static address for both central and peripheral should be defined.

Split keyboard project

A project of split keyboard could be like:

src
 - bin
   - central.rs
   - peripheral.rs
keyboard.toml
Cargo.toml

Binary size

RMK has included many optimizations by default to of binary size. But there are still some tricks to reduce the binary size more. If you got linker error like:

= note: rust-lld: error: 
        ERROR(cortex-m-rt): The .text section must be placed inside the FLASH memory.
        Set _stext to an address smaller than 'ORIGIN(FLASH) + LENGTH(FLASH)'

or some errors occur when writing configs to flash, that means that your microcontroller's internal flash is not big enough.

There are several approaches to solve the problem:

Change DEFMT_LOG level

Logging is quite useful when debugging the firmware, but it requires a lot of flash. You can change the default logging level to error at .cargo/config.toml, to print only error messages and save flash:

# .cargo/config.toml

[env]
- DEFMT_LOG = "debug"
+ DEFMT_LOG = "error"

Use panic-halt

By default, RMK uses panic-probe to print error messages if panic occurs. But panic-probe actually takes lots of flash because the panic call can not be optimized. The solution is to use panic-halt instead of panic-probe:

# In your binary's Cargo.toml

- panic-probe = { version = "1.0", features = ["print-defmt"] }
+ panic-halt = "1.0"

The in main.rs, use panic-halt instead:

// src/main.rs

- use panic_probe as _;
+ use panic_halt as _;

Remove defmt-rtt

You can also remove the entire defmt-rtt logger to save flash.

# In your binary's Cargo.toml 
- defmt-rtt = "1.0"

In this case, you have to implement an empty defmt logger.

# src/main.rs
- use defmt_rtt as _;

+ #[defmt::global_logger]
+ struct Logger;
+ 
+ unsafe impl defmt::Logger for Logger {
+     fn acquire() {}
+     unsafe fn flush() {}
+     unsafe fn release() {}
+     unsafe fn write(_bytes: &[u8]) {}
+ }

Enable unstable feature

According to embassy's doc, you can set the following in your .cargo/config.toml

[unstable]
build-std = ["core"]
build-std-features = ["panic_immediate_abort"]

And then compile your project with nightly Rust:

cargo +nightly build --release
# Or
cargo +nightly size --release

This config will reduce about 4-6kb of binary size furthermore.

After applying all above approaches, total binary size of stm32h7 example can be reduced from about 93KB to 54KB, which means the binary size decreases about 42%!

Make storage optional

Making storage feature optional and marking sequential-storage dependency as optional could also reduce the binary size a lot.

This work is not done yet, if there is still binary size issue for your microcontroller, please fire an issue at https://github.com/HaoboGu/rmk/issues and let us know! We'll improve the priority of this feature if we got sufficient feedback.

Any PRs are also welcomed.

Use Rust

By default, the generated project uses keyboard.toml to config the RMK keyboard firmware. If you want to customize your firmware using Rust, there're steps to do to make the generated firmware project compile:

Update memory.x

memory.x is the linker script of Rust embedded project, it's used to define the memory layout of the microcontroller. RMK enables memory-x feature for embassy-stm32, so if you're using stm32, you can just ignore this step.

For other ARM Cortex-M microcontrollers, you only need to update the LENGTH of FLASH and RAM to your microcontroller.

If you're using nRF52840, ensure that you have Adafruit_nRF52_Bootloader flashed to your board. Most nice!nano compatible boards have it already. As long as you can open a USB drive for your board and update uf2 firmware by dragging and dropping, you're all set.

You can either checkout your microcontroller's datasheet or existing Rust project of your microcontroller for it.

Update main.rs

By default, generated main.rs uses proc-macro and keyboard.toml. To fully customize the firmware, you can copy the code from RMK's Rust example, such as https://github.com/HaoboGu/rmk/blob/main/examples/use_rust/rp2040/src/main.rs to src/main.rs.

Next, you have to check src/main.rs, make sure that the binded USB interrupt is right. Different microcontrollers have different types of USB peripheral, so does bind interrupt. You can check out Embassy's examples for how to bind the USB interrupt correctly.

For example, if you're using stm32f4, there is an usb serial example there. And code for binding USB interrupt is at line 15-17:

#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
    OTG_FS => usb::InterruptHandler<peripherals::USB_OTG_FS>;
});
}

Add your own layout

The next step is to add your own keymap layout for your firmware. RMK supports vial app, an open-source cross-platform(windows/macos/linux/web) keyboard configurator. So the vial like keymap definition has to be imported to the firmware project.

Fortunately, RMK does most of the heavy things for you, all you need to do is to create your own keymap definition and convert it to vial.json following vial's doc here, and place it at the root of the firmware project, replacing the default one. RMK would do all the rest things for you.

Add your default keymap

After adding the layout of your keyboard, the default keymap should also be updated. The default keymap is defined in src/keymap.rs, update keyboard matrix constants and add a get_default_keymap() function which returns the default keymap of your keyboard.

RMK provides a bunch of useful macros helping you define your keymap. Check out keymap_configuration chapter for more details. You can also check src/keymap.rs files under https://github.com/HaoboGu/rmk/blob/main/examples/use_rust examples for reference.

Some KeyActions are not supported by the macros, plain KeyActions also work, for example: KeyAction::TapHold(Action::Key(KeyCode::Kc1), Action::Key(KeyCode::Kc2))

Define your matrix

Next, you're going to change the IO pins of keyboard matrix making RMK run on your own PCB. Generally, IO pins are defined in src/main.rs. RMK will generate a helper macro to help you to define the matrix. For example, if you're using rp2040, you can define your pins using config_matrix_pins_rp!:

#![allow(unused)]
fn main() {
let (input_pins, output_pins) = config_matrix_pins_rp!(
    peripherals: p,
    input: [PIN_6, PIN_7, PIN_8, PIN_9],
    output: [PIN_19, PIN_20, PIN_21]
);
}

input and output are lists of used pins, change them accordingly.

If your keys are directly connected to the microcontroller pins, you can define your pins like this:

#![allow(unused)]
fn main() {
    let direct_pins = config_matrix_pins_rp! {
        peripherals: p,
        direct_pins: [
            [PIN_0, PIN_1,  PIN_2],
            [PIN_3, _,  PIN_5],
        ]
    };
}

So far so good, you've done all necessary modifications of your firmware project. You can also check TODOs listed in the generated README.md file.

Rotary encoders

toml configurationn

You can define a rotary encoder in your keyboard.toml. The default type of rotary encoder is EC11, you can config it as:

[[input_device.encoder]]
pin_a = "P0_30"
pin_b = "P0_31"

# Whether to use the MCU's internal pull-up resistor, default to false
internal_pullup = false

# Phase is the working mode of the rotary encoders.
# Available mode: 
# - default: EC11 compatible, resolution = 1
# - e8h7: resolution = 2, reverse = true
# - resolution: customized resolution, the resolution value and reverse should be specified later
phase = "default" 

# The resolution represents how many pulses the encoder generates per detent.
# For examples, if your rotary encoder has 30 detents in total and generates 15 pulses per 360 degree rotation, then the resolution = 30/15 = 2.
# Number of detents and number of pulses can be found in your encoder's datasheet
resolution = 2

# Whether the direction of the rotary encoder is reversed.
reverse = false

Multiple encoders can be added directly, the encoder index is determined by the order:

# Encoder 0
[[input_device.encoder]]
pin_a = "P0_01"
pin_b = "P0_02"
phase = "default" 
# Encoder 1
[[input_device.encoder]]
pin_a = "P0_03"
pin_b = "P0_04"
phase = "default" 

Rust configuration

With Rust, you can define a rotary encoder as the following:

#![allow(unused)]
fn main() {
    use rmk::input_device::rotary_encoder::RotaryEncoder;
    use rmk::input_device::rotary_encoder::DefaultPhase;
    let pin_a = Input::new(p.P1_06, embassy_nrf::gpio::Pull::None);
    let pin_b = Input::new(p.P1_04, embassy_nrf::gpio::Pull::None);
    let mut encoder = RotaryEncoder::with_phase(pin_a, pin_b, DefaultPhase, encoder_id);
}

You can also use the resolution based phase:

#![allow(unused)]
fn main() {
    use rmk::input_device::rotary_encoder::RotaryEncoder;
    let pin_a = Input::new(p.P1_06, embassy_nrf::gpio::Pull::None);
    let pin_b = Input::new(p.P1_04, embassy_nrf::gpio::Pull::None);
    // Create an encoder with resolution = 2, reversed = false
    let mut encoder = RotaryEncoder::with_resolution(pin_a, pin_b, 2, false, encoder_id)
}

After creating the rotary encoder device, a corresponding processor is also needed:

#![allow(unused)]
fn main() {
    use rmk::input_device::rotary_encoder::RotaryEncoderProcessor;
    let mut encoder_processor = RotaryEncoderProcessor::new(&keymap);
}

Lastly, add them to the finally runner:

#![allow(unused)]
fn main() {
    join4(
        run_devices! (
            (matrix, encoder) => EVENT_CHANNEL,
        ),
        run_processor_chain! {
            EVENT_CHANNEL => [encoder_processor],
        },
        keyboard.run(), // Keyboard is special
        run_rmk(&keymap, driver, storage, light_controller, rmk_config, sd),
    )
    .await;
}

Joysticks

Notice:
1. You need to use a debug probe to find your parameters now.
2. Only Nrf is supported now.

TODO:

  • a more intuitive way to configure the joystick via rmk-gui
  • more functions besides mouse

toml configuration

[[input_device.joystick]]
name = "default"
pin_x = "P0_31"
pin_y = "P0_29"
pin_z = "_"
transform = [[80, 0], [0, 80]]
bias = [29130, 29365]
resolution = 6
# func = "mouse | n-direction key" # TODO: only mouse is supported now

Parameters:

  • name: the unique name of the joystick. If you have multiple joysticks, you should give them different names
  • pin_x: the pin of the x-axis
  • pin_y: the pin of the y-axis
  • pin_z: the pin of the z-axis
  • transform: the transformation matrix of the joystick
  • bias: the bias of each axis
  • resolution: the resolution of each axis

Axis:

The _ stands for the axis is not existed.

_ is only allowed for:

  1. both y and z axis
  2. only z axis.

e.g. pin_x = "_" pin_y = "P0_29" pin_z = "P0_30" is not allowed

How it works

Notice:
the transform might work not so intuitively,
please read the document below for more information.
  1. The device read axes

  2. Add bias on each axis to make them into 0 when the joystick is released because the number returned by the ADC device is u16.

  3. About the transform

    1. new x-axis's value = (axis_x + bias[0]) / transform[0][0] + (axis_y + bias[1]) / transform[0][1] + (axis_z + bias[2]) / transform[0][2]
    2. new y-axis's value = (axis_x + bias[0]) / transform[1][0] + (axis_y + bias[1]) / transform[1][1] + (axis_z + bias[2]) / transform[1][2]
    3. new z-axis's value = (axis_x + bias[0]) / transform[2][0] + (axis_y + bias[1]) / transform[2][1] + (axis_z + bias[2]) / transform[2][2]

    If transform[new axis][old axis] is 0, the old axis value will be ignored.

    For most situation the value boundary read by the ADC device is far larger than -256~255 which is the range of the mouse report, that is why the transform is designed to be the divisor.

  4. Each axis will be changed into the maximum integer multiples of resolution smaller than its original value.

    Because the values read by the ADC device may have noises.

How to find configuration for your circuit quickly

  1. Firstly, set the bias to 0, resolution to 1 and transform to [[1, 0, 0], [0, 1, 0], [0, 0, 1]] (the identity 2-d array's dimension depends on how many axes the joystick has).

  2. Find the best bias,

    Using the debug probe, in the debug information, there is the output JoystickProcessor::generate_report: record = [axis_x, axis_y, axis_z] Observe the value, and you can get the bias which make each axis as close to 0 as possible.

  3. Try to use the joystick, if you notice the mouse moves too fast, you can adjust the transform larger till you fond.

  4. If your mouse is jitter, you should adjust the resolution larger till you fond.

rust configuration

Because the joystick and battery use the same ADC peripheral, they actually use the same NrfAdc input_device.

If the light_sleep is not None, the NrfAdc will enter light sleep mode when no event is generated after 1200ms, and the polling interval will be reduced to the value assigned.

#![allow(unused)]
fn main() {
let saadc_config = saadc::Config::default();
let adc = saadc::SAADC::new(p.SAADC, Irqs, saadc_config,
    [
        saadc::ChannelConfig::SingleEnded(saadc::VddhDiv5Input.degrade_saadc()),
        saadc::ChannelConfig::SingleEnded(p.P0_31.degrade_saadc()),
        saadc::ChannelConfig::SingleEnded(p.P0_29.degrade_saadc())
    ],
);
saadc.calibrate().await;
let mut adc_dev = NrfAdc::new(adc, [AnalogEventType::Battery, AnalogEventType::Joystick(2)], 20 /* polling interval */, Some(350)/* light sleep interval */);
let mut batt_proc = BatteryProcessor::new(1, 5, &keymap);
let mut joy_proc = JoystickProcessor::new([[80, 0], [0, 80]], [29130, 29365], 6, &keymap);
...
run_devices! (
    (matrix, adc_dev) => EVENT_CHANNEL,
),
run_processor_chain! {
    EVENT_CHANNEL => [joy_proc, batt_proc],
}
...
}

Roadmap

There are a bunch of things to do with RMK in the near future. I plan to ship 1.0.0 after all the following items are accomplished.

Roadmap to 1.0.0

MarkDescription
🔴important
🟢easy
🔵heavy work

keyboard feature

  • layer support
  • system/media/mouse keys
  • LED
  • tap/hold
  • keyboard macros
  • async key detection and report sending
  • 🔵 split keyboard support
  • Direct pin
  • 🟢 encoder
  • 🔵 Input device
  • 🔴 RGB
  • 🔵 display support

Wireless

  • BLE support - nRF
  • auto switch between BLE/USB
  • battery service from ADC
  • 🔴 BLE support - esp32c3 and esp32s3
  • sleep mode to save battery
  • 🔵 universal BLE wrapper, including BLE management, battery management, supports both nRF and ESP
  • stablizing BLE feature gate/API
  • support more MCUs, such as cyw(used in rp2040w/rp2350w)

User experience

  • vial support
  • easy keyboard configuration with good default, support different MCUs
  • CLI and GUI tool for project generation, firmware compilation, etc
  • Versioned documentation site, better documentation
  • making vial and default keymap consistent automatically
  • 🔴🔵 GUI keymap configurator which supports windows/macos/linux/web
  • default bootloader
  • USB DFU/OTA

If you want to contribute, please feel free to open an issue or PR, or just ping me! Any forms of contribution are welcome :D

Contributing

ANY contributions are welcome! There is a simple step by step guide for developers:

  1. Before you start, you may want to read the [under the hood] section to understand how RMK works. Github Issue is also a good place for questions.

  2. Checkout the active PRs, make sure that what you want to add isn't implemented by others.

  3. Write your code!

  4. Open a PR merging your code to main repo, make sure all CIs pass.

Under the hood

If you're not familiar with RMK, the following is a simple introduction of source code of RMK.

Project architecture

There're two crates in RMK project, rmk and rmk-macro.

rmk-macro is a proc-macro helper of RMK, it reads keyboard.toml config file and converts toml config to RMK config, generates the boilerplate code. The core of RMK stays in rmk crate.

So, if you want to contribute new features of RMK, just look into rmk core crate. If you want to add new chip support, rmk and rmk-macro should be updated, so that users could use keyboard.toml to config keyboard with your new chip. And, if you want to add new configurations, look into both rmk-macro/config and rmk/config.

RMK core

rmk crate is the main crate, it provides several entry API to start the keyboard firmware. All the entry APIs are similar, it:

  • Initialize the storage, keymap and matrix first
  • Create services: main keyboard service, matrix service, usb service, ble service, vial service, light service, etc.
  • Run all tasks in an infinite loop, if there's a task failed, wait some time and rerun

Generally, there are 4-5 running tasks in the meanwhile, according to the user's config. Communication between tasks is done by channels.There are several built-in channels:

  • FLASH_CHANNEL: a multi-sender, single-receiver channel. There are many tasks send the FlashOperationMessage, such as BLE task(which saves bond info), vial task(which saves key), etc.
  • KEY_EVENT_CHANNEL: a multi-sender, single-receiver channel. The sender can be a matrix task which scans the key matrix or a split peripheral manager which receives key event from split peripheral. The receiver, i.e. keyboard task, receives the key event and processes the key
  • EVENT_CHANNEL: a multi-sender, single-receiver channel. It's used for all events from input devices except KeyEvent
  • KEYBOARD_REPORT_CHANNEL: a single-sender, single-receiver channel, keyboard task sends keyboard report to channel after the key event is processed, and USB/BLE task receives the keyboard report and sends the key to the host.

Matrix scanning & key processing

An important part of a keyboard firmware is how it performs matrix scanning and how it processes the scanning result to generate keys.

In RMK, this work is done in Matrix and Keyboard respectively. The Matrix scans the key matrix and send KeyEvent if there's a key change in matrix. Then the Keyboard receives the KeyEvent and processes it into actual keyboard report. Finally, the keyboard report is sent to USB/BLE tasks and forwarded to the host via USB/BLE.