RMK
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:
RMK | Keyberon | QMK | ZMK | |
---|---|---|---|---|
Language | Rust | Rust | C | C |
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 key | ✅ | ✅ | Limited | |
Keyboard configuration | toml (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:
- Cloud compilation: compile the firmware using Github Actions, no need to install anything to your computer, ideal for new beginners
- 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
- To get started, open project-template, click
Use this template
button and chooseCreate a new repository
:
- Input your repository name, and click
Create repository
-
After the repository is created, there are two config files in the project:
keyboard.toml
andvial.json
:keyboard.toml
: this file defines almost everything about your keyboard, follow keyboard configuration to create your own keyboard definitionvial.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 createvial.json
for your keyboard.
you can edit the files directly on Github by clicking the file and then choosing
edit this file
: . After updating your config, clickCommit changes..
to save it: -
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.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 underSummary/Artifacts
: -
Now you get your RMK firmware! RMK provides
hex
anduf2
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
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, 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 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, 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
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 generatehex
/bin
file, you need cargo-binutils. You can usecargo install cargo-binutils rustup component add llvm-tools
to install it. Then, you can use the following command to generate
hex
orbin
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 generateuf2
file automatically. CheckMakefile.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(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.
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.
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:
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
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.
Currently, col2row is used as the default matrix type. If you want to use row2col matrix, you should edit your Cargo.toml
, disable the default feature as the following:
# Cargo.toml
rmk = { version = "0.4", default-features = false, features = ["nrf52840_ble"] }
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.
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:
-
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 toKeyCode::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 likeLShift | RGui
to have multiple modifiers active. -
For no-key, use
"__"
-
RMK supports many advanced layer operations:
- Use
"DF(n)"
to create a switch default layer actiov,n
is the layer number - Use
"MO(n)"
to create a layer activate action,n
is the layer number - Use
"LM(n, modifier)"
to create layer activate with modifier action. The modifier can be chained in the same way asWM
- Use
"LT(n, key)"
to create a layer activate action or tap key(tap/hold). Thekey
here is the RMKKeyCode
- Use
"OSL(n)"
to create a one-shot layer action,n
is the layer number - Use
"OSM(modifier)"
to create a one-shot modifier action. The modifier can be chained in the same way asWM
- Use
"TT(n)"
to create a layer activate or tap toggle action,n
is the layer number - Use
"TG(n)"
to create a layer toggle action,n
is the layer number - Use
"TO(n)"
to create a layer toggle only action (activate layern
and deactivate all other layers),n
is the layer number
- Use
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.
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, theprior_idle_time
setting becomes functional. Defaults tofalse
.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 totrue
. 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 thehold_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"
[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"
# 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 }
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 = 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 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" },
]
# 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!
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.rs
s 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 KeyAction
s 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 profileUser8
: switch to next profileUser9
: switch to previous profileUser10
: clear current profile bond infoUser11
: 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:
-
Enable
exti
feature of yourembassy-stm32
dependency -
Ensure that your input pins don't share same EXTI channel
-
If you're using
keyboard.toml
, nothing more to do. The[rmk_keyboard]
macro will check yourCargo.toml
and do the work for you. But if you're using Rust code, you need to useExtiInput
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
# 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 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/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 pins
[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]]
# Matrix definition
rows = 2
cols = 1
row_offset = 2
col_offset = 2
# Peripheral's ble addr
ble_addr = [0x7e, 0xfe, 0x73, 0x9e, 0x11, 0xe3]
[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]
[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" }]
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.
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.
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.
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
Mark | Description |
---|---|
🔴 | 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
- 🔵 Input device
- 🔵 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
- Versioned documentation site, better documentation
- CLI and GUI tool for project generation, firmware compilation, etc
- 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:
-
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.
-
Checkout the active PRs, make sure that what you want to add isn't implemented by others.
-
Write your code!
-
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 theFlashOperationMessage
, 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 keykeyboard_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.