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 aims to introduce you how to build your own keyboard firmware using RMK and run it on your microcontroller. There are 3 steps of the guide:

  • setup the RMK environment

  • create a RMK project

  • compile the firmware and flash

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

Setup RMK environment

In this section, you'll setup the Rust development environment, install all needed components for compiling and flashing RMK.

1. Install Rust

RMK is written in Rust, so first you have to install Rust to your host. 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. Choose one of the supported microcontroller makes your journey of RMK much easier. In RMK repo, there are many examples, microcontrollers in 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 include 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, if 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

4. Add other tools

There are several other tools are highly recommended:

  • cargo generate: needed for creating a RMK firmware project from RMK project template.

  • probe-rs: used to flash and debug your firmware. Here is the installation instruction.

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

You can use the following commands to install them:

  # Install cargo-generate and flip-link
  cargo install cargo-generate flip-link

  # 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

Now you're all set for RMK! In the next section, you'll learn how to create your own RMK firmware project.

Create RMK firmware project

In this section, you'll create your own RMK firmware project using RMK project template and cargo-generate.

1. Create from template

RMK provides a project template, making it much easier to create your own firmware using your favorite microcontroller. cargo-generate is required to use the template, you can install it using the following command(if you've installed cargo-generate, just skip this):

cargo install cargo-generate

Then you can create your RMK firmware project with a single command:

cargo generate --git https://github.com/HaoboGu/rmk-template

This command would ask you to fill some basic info of your project, it requires a little bit deeper understanding of your chosen hardware. If you don't know what to fill, check this section in overview first. The following is an example. In the example, a stm32 microcontroller stm32h7b0vb is used, the corresponding target is thumbv7em-none-eabihf:

$ cargo generate --git https://github.com/HaoboGu/rmk-template
🤷   Project Name: rmk-demo
🔧   Destination: /Users/haobogu/Projects/keyboard/rmk-demo ...
🔧   project-name: rmk-demo ...
🔧   Generating template ...
✔ 🤷   Choose your microcontroller family · stm32
✔ 🤷   Choose your microcontroller's target · thumbv7em-none-eabihf
🤷   Enter your MCU model(Embassy feature name): stm32h7b0vb
️️👉👉👉 For the following steps, search 'TODO' in generated project
🔧   Moving generated files into: `/Users/haobogu/Projects/keyboard/rmk-demo`...
🔧   Initializing a fresh Git repository
✨   Done! New project created /Users/haobogu/Projects/keyboard/rmk-demo

A RMK firmware project will be automatically created after you fill out all required fields. Use code <your-project-name> to open the project in VSCode. If you're lucky enough, you project could just compile with cargo build command! But for the most of the cases, there are minor modifications you have to do. There are two ways to use config your RMK keyboard in your firmware project:

  • use a config file: keyboard.toml

    For new users, it's recommend to use keyboard.toml to config your keyboard. This config file contains almost all about your keyboard, with it, you can create your firmware very conveniently, no Rust code needed! Please check Keyboard Configuration feature for configuration details.

  • use Rust code

    If the configuration doesn't satisfy all your needs(it would mostly do!), you can write your own Rust code to do more customization! RMK also provides some examples to help you quickly get throught it.

Use keyboard.toml

The generated main.rs should be like:

#![allow(unused)]
fn main() {
use rmk::macros::rmk_keyboard;
use vial::{VIAL_KEYBOARD_DEF, VIAL_KEYBOARD_ID};

#[rmk_keyboard]
mod keyboard {}
}

There's a macro rmk_keyboard that does the magic for you. This macro will automatically read the keyboard.toml in your project root and generate all boilerplate code for you.

There're steps you have to do to customize your own firmware:

Edit keyboard.toml

The generated keyboard.toml should have some fields configured from cargo generate. 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 of 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 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, generally you have to change start address in memory.x to 0x27000 or 0x26000, according to your softdevice version. For example, softdevice v6.1.x should use 0x00026000 and v7.3.0 should be 0x00027000

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

Add your own layout

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 would do all the rest things for you.

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, generally you have to change start address in memory.x to 0x27000 or 0x26000, according to your softdevice version. For example, softdevice v6.1.x should use 0x00026000 and v7.1.x should be 0x00027000

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_otg::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.

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.

Compile and flash!

In this section, you'll be able to compile your firmware and flash it to your microcontroller.

Compile the firmware

To compile 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.

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

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.

First, install the cargo-make tool:

cargo install --force cargo-make

Generating uf2 firmware also requires you have python command available. Here is a guide for installing python.

Then, you should update the chip family argument(aka argument after -f) in Makefile.toml in your project. You can get your chip's family ID in scripts/uf2conv.py.

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

cargo make uf2 --release

to generate your uf2 firmware.

Tips for nRF52840

For nRF52840, there are several widely used UF2 bootloaders, they require slight different configs.

First, you should check the used softdevice version of your bootloader. Enter bootloader mode, there will be an USB driver shown in your computer. Open INFO_UF2.TXT in the USB driver, the content of INFO_UF2.TXT should be like:

UF2 Bootloader 0.6.0 lib/nrfx (v2.0.0) lib/tinyusb (0.10.1-41-gdf0cda2d) lib/uf2 (remotes/origin/configupdate-9-gadbb8c7)
Model: nice!nano
Board-ID: nRF52840-nicenano
SoftDevice: S140 version 6.1.1
Date: Jun 19 2021

As you can see, the version of softdevice is S140 version 6.1.1. For nRF52840, RMK supports S140 version 6.X and 7.X. The memory.x config is slightly different for softdevice 6.X and 7.X:

MEMORY
{
  /* These values correspond to the NRF52840 with Softdevices S140 6.1.1 */
  /* FLASH : ORIGIN = 0x00026000, LENGTH = 824K */

  /* These values correspond to the NRF52840 with Softdevices S140 7.3.0 */
  FLASH : ORIGIN = 0x00027000, LENGTH = 820K
  RAM : ORIGIN = 0x20020000, LENGTH = 128K
}

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

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
    

    This script requires you have python command available in your commandline. Some platforms have python3 command only, you can change python in Makefile.toml to python3 in this case.

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(Hertz(8_000_000));
        config.rcc.sys_ck = Some(Hertz(48_000_000));
        config.rcc.pclk1 = Some(Hertz(24_000_000)); 
        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.6", features = [
    "defmt",
    "arch-cortex-m",
    "task-arena-size-32768",
    "executor-thread",
    "integrated-timers",
] }

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.6", features = [
    "defmt",
    "arch-cortex-m",
-   "task-arena-size-32768",
+   "task-arena-size-65536",
    "executor-thread",
    "integrated-timers",
] }

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.

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

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 a configuration file. 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! #[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 full 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. 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 is the introduction of each section:

[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 key matrix 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 `[spilt]` section.

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, configs here do nothing actually
# row2col = true

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 is `true`.
direct_pin_low_active = true

[layout]

[layout] section contains the layout and the default keymap for the keyboard:

[layout]
rows = 4
cols = 3
layers = 2
keymap = [
  # Your default keymap here
]

The keymap inside is a 2-D array, which represents layer -> row -> key structure of your keymap:

keymap = [
  # Layer 1
  [
    ["key1", "key2"], # Row 1
    ["key1", "key2"], # Row 2
    ...
  ],
  # Layer 2
  [
    [..], # Row 1
    [..], # Row 2
    ...
  ],
  ...
]

The number of rows/cols in default keymap should be identical with what's already defined. Here is an example of keymap definition.

If the number of layer in default keymap is smaller than defined layer number, RMK will fill empty layers automatically. 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 num.

In each row, some keys are set. Due to the limitation of toml file, all keys are strings. RMK would parse the strings and fill them to actual keymap initializer, like what's in keymap.rs

The key 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!

    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.

  2. For no-key, use "__"

  3. RMK supports many advanced layer operations:

    1. Use "DF(n)" to create a switch default layer actiov, 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.

[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.

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"

[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 }

[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

[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"
# 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 }

More customization

#[rmk_keyboard] macro also provides some flexibilities of customizing the keyboard's behavior. For example, the clock config:

#![allow(unused)]
fn main() {
#[rmk]
mod MyKeyboard {
  use embassy_stm32::Config;

  #[config]
  fn config() -> Config {
    let mut config = Config::default();
    {
        use embassy_stm32::rcc::*;
        config.rcc.hsi = Some(HSIPrescaler::DIV1);
        // ... other rcc configs below
    }
    config
  }
}
}

RMK should use the config from the user defined function to initialize the singleton of chip peripheral, for stm32, you can assume that it's initialized using let p = embassy_stm32::init(config);.

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, configs here do nothing actually

# 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 = 4
# Number of cols. For split keyboard, this is the total cols contains all splits
cols = 3
# Number of layers. Be careful, since large layer number takes more flash and RAM
layers = 2
# Default keymap definition, the size should be consist with rows/cols
# Empty layers will be used to fill if the number of layers set in default keymap is less than `layers` setting
keymap = [
    [
        ["A", "B", "C"],
        ["Kc1", "Kc2", "Kc3"],
        ["LCtrl", "MO(1)", "LShift"],
        ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"]
    ],
    [
        ["_", "TT(1)", "TG(2)"],
        ["_", "_", "_"],
        ["_", "_", "_"],
        ["_", "_", "_"]
    ],
]

# 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 has 2000/2806 according to its schematic: https://dos.nicekeyboards.com/#/nice!nano/pinout_schematic, which means that the voltage at adc pin is VBat * 2000/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" },
]
# 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]

# 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]
# 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!

TODOs:

  • gen keymap from keyboard.toml
  • read vial.json and gen

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 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 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.

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.

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(Input::new(p.PD9, Pull::Down).degrade(), p.EXTI9.degrade());
    let pd8 = ExtiInput::new(Input::new(p.PD8, Pull::Down).degrade(), p.EXTI8.degrade());
    let pb13 = ExtiInput::new(Input::new(p.PB13, Pull::Down).degrade(), p.EXTI13.degrade());
    let pb12 = ExtiInput::new(Input::new(p.PB12, Pull::Down).degrade(), p.EXTI12.degrade());
    let input_pins = [pd9, pd8, pb13, pb12];

    // ...Other initialization code

    // Run RMK
    run_rmk(
        input_pins,
        output_pins,
        driver,
        f,
        &mut get_default_keymap(),
        keyboard_config,
        spawner,
    )
    .await;

}

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

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,
};
let keyboard_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/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/nrf52840_ble_split for the keyboard.toml + wireless split keyboard example using nRF52840.

NOTE: for nrf52840_ble_split, add

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

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

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

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

#![allow(unused)]
fn main() {
// nRF52840 split central, arguments might be different for other microcontrollers, check the API docs for the detail.
run_rmk_split_central::<
            Input<'_>,
            Output<'_>,
            Driver<'_, USBD, &SoftwareVbusDetect>,
            ROW, // TOTAL_ROW
            COL, // TOTAL_COL
            2, // CENTRAL_ROW
            2, // CENTRAL_COL
            0, // CENTRAL_ROW_OFFSET
            0, // CENTRAL_COL_OFFSET
            NUM_LAYER,
        >(
            input_pins,
            output_pins,
            driver,
            &mut get_default_keymap(),
            keyboard_config,
            central_addr,
            spawner,
        )
}

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

#![allow(unused)]
fn main() {
run_peripheral_monitor<
    2, // PERIPHERAL_ROW
    1, // PERIPHERAL_COL
    2, // PERIPHERAL_ROW_OFFSET
    2, // PERIPHERAL_COL_OFFSET
  >(peripheral_id, peripheral_addr)
}

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() {
run_rmk_split_peripheral::<Input<'_>, Output<'_>, 2, 2>(
    input_pins,
    output_pins,
    central_addr,
    peripheral_addr,
    spawner,
)
}

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

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 matrix pins 
input_pins = ["P0_12", "P0_13"]
output_pins = ["P0_14", "P0_15"]
# Central's ble addr
ble_addr = [0x18, 0xe2, 0x21, 0x80, 0xc0, 0xc7]

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

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

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] and [[split.peripheral]]. The rows/cols in [matrix] section is the total number of rows/cols of the whole keyboard.

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" }]

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.

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 = "0.3", features = ["print-defmt"] }
+ panic-halt = "0.2"

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 = "0.4"

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.

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
  • 🔴 RGB
  • 🟢 encoder
  • 🔵 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
  • BLE support - ch58x/ch59x

User experience

  • vial support
  • easy keyboard configuration with good default, support different MCUs
  • making vial and default keymap consistent automatically
  • 🔴🔵 GUI 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 three crates in RMK project, rmk, rmk-config and rmk-macro.

rmk-config crate is the dependency of both rmk and rmk-macro, it includes both toml configs used in keyboard.toml and normal config used in RMK core. 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 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 monitor which receives key event from split peripheral. The receiver, i.e. keyboard task, receives the key event and processes the key
  • 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.

Contribute to Documents

This project's documentation is built using mdBook.

I18n (Internationalization)

This project's documentation uses mdbook-i18n-helpers for internationalization.

Welcome to help us translate this project into your language!

Initialize a New Translation

See mdbook-i18n-helpers/USAGE.md

First, you need to install Gettext. For Windows users, you can use this.

Generate the pot template. (messages.pot doesn't need to be uploaded to the repo)

MDBOOK_OUTPUT='{"xgettext": {"depth": "1"}}' \
  mdbook build -d po/messages

Then, create a po file for your language. They are named after the ISO 639 language codes: Danish would go into po/da.po, Korean would go into po/ko.po, etc.

msginit -i po/messages.pot -l xx -o po/xx.po

Next, add a li for your language in the language-list in docs\theme\index.hbs. Like this:

<li role="none"><button role="menuitem" class="theme">
    <a id="zh_CN">Chinese Simplified (简体中文)</a>
</button></li>

Then, add your language code to env:TRANSLATED_LANGUAGES in .github\workflows\docs.yml.

Continue Translating an Existing Translation

You can install a .po file editor, such as Poedit. Then open the .po file and translate. Currently, there's no need to compile to .mo files.

Updating an Existing Translation

As the source text changes, translations gradually become outdated. To update the po/xx.po file with new messages, first extract the source text into a po/messages.pot template file. Then run

msgmerge --update po/xx.po po/messages.pot

Unchanged messages will stay intact, deleted messages are marked as old, and updated messages are marked "fuzzy". A fuzzy entry will reuse the previous translation: you should then go over it and update it as necessary before you remove the fuzzy marker.