From v0.8 to v0.9

RMK v0.9 is a major release with lots of updates and improvements. The key changes in v0.9 include:

  • keyboard.toml restructuring: layers move out of [layout] into a new [keymap] section, and the config is validated more strictly at build time.

  • New Rust API (Rust API users only): input devices, input processors, and controllers are merged into one event/processor system, and run_rmk is replaced by explicit transports plus run_all!.

  • New features: display support, a BLE dongle, and a hardware watchdog that is enabled by default. Rynk (a native alternative to Vial) and DFU firmware update ship as experimental — Vial remains the default and needs no changes.

  • Dependency upgrades: newer embassy releases, bt-hci v0.10, and updated BLE stacks.

If you configure your keyboard with keyboard.toml, only the Cargo.toml and keyboard.toml sections apply to you. The Rust API section is for keyboards written against the Rust API.

Cargo.toml Update

Update RMK and embassy-* dependencies

Update the rmk version to v0.9 and adjust its feature flags: the controller and col2row features are removed, and vial_lock is renamed to host_lock. Update embassy-* dependencies and HALs as well (embassy-time is unchanged):

- rmk = { version = "0.8", features = [
+ rmk = { version = "0.9", features = [
    "nrf52840_ble",
    "split",
    "async_matrix",
-   "controller", # Removed
-   "col2row", # Removed
-   "vial_lock", # Renamed
+   "host_lock",
] }
- embassy-executor = { version = "0.9", features = ["arch-cortex-m", ...] }
+ embassy-executor = { version = "0.10", features = ["platform-cortex-m", ...] }
- embassy-nrf = { version = "0.8" }
+ embassy-nrf = { version = "0.11" }
- embassy-rp = { version = "0.8" }
+ embassy-rp = { version = "0.10" }
- embassy-stm32 = { version = "0.4" }
+ embassy-stm32 = { version = "0.6" }
- bt-hci = { version = "0.6", features = ["defmt"] }
+ bt-hci = { version = "0.10", features = ["defmt"] }

watchdog is a new default feature. If your rmk dependency sets default-features = false, add "watchdog" to the feature list to get the hardware watchdog (see Watchdog).

For BLE keyboards, the rand, rand_core, and rand_chacha dependencies can be removed — the BLE transport builds the stack itself (see the Rust API update).

nRF specific update

For nRF, replace the nrf-sdc git pin with the crates.io release and enable nrf-sdc's central feature — the BLE transport needs it even on a non-split keyboard:

- nrf-sdc = { git = "https://github.com/alexmoon/nrf-sdc", rev = "11d5c3c", features = ["defmt", "peripheral", "nrf52840"] }
+ nrf-sdc = { version = "0.4.0", features = ["defmt", "peripheral", "central", "nrf52840"] }
- nrf-mpsl = { git = "https://github.com/alexmoon/nrf-sdc", rev = "11d5c3c", ..
+ nrf-mpsl = { version = "0.4.0", ..

Rust API users: the new nrf-sdc's support_* builder methods no longer return Result, so drop the ? on those calls.

Pico W specific update

For Pico W, update cyw43 and cyw43-pio, and replace your whole [patch.crates-io] block with the one from the pi_pico_w_ble example — it now pins the complete embassy graph to one git revision, not just three crates:

- cyw43 = { version = "0.5.0", features = ["defmt", "firmware-logs", "bluetooth"] }
+ cyw43 = { version = "0.7", features = ["defmt", "firmware-logs", "bluetooth"] }
- cyw43-pio = { version = "0.8.0", features = ["defmt"] }
+ cyw43-pio = { version = "0.10.0", features = ["defmt"] }

cyw43 also needs a fourth firmware blob, nvram_rp2040.bin, next to the existing three in cyw43-firmware/. If your build.rs downloads the blobs, add it to the download list; otherwise copy the file from the example. Rust API users also port the updated cyw43 task wiring from the example.

ESP32 specific update

For ESP32, update all esp-* dependencies, drop rand_core and static_cell, and copy the [patch.crates-io] block from the esp32c3_ble example — it pins the whole esp-hal graph to a git revision, because the released esp-radio still speaks bt-hci 0.8 instead of the bt-hci-transport trait RMK needs:

- esp-hal = { version = "1.0", features = ["esp32c3", "unstable"] }
+ esp-hal = { version = "1.2.0-rc.0", features = ["esp32c3", "unstable"] }
- esp-radio = { version = "0.17", features = ["esp32c3", "unstable", "ble"] }
+ esp-radio = { version = "1.0.0-beta.0", features = ["esp32c3", "unstable", "ble"] }
- esp-rtos = { version = "0.2", features = ["esp32c3", "esp-radio", "embassy"] }
+ esp-rtos = { version = "0.3", features = ["esp32c3", "esp-radio", "embassy"] }
- esp-backtrace = { version = "0.18", .. }
+ esp-backtrace = { version = "0.19", .. }
- esp-storage = { version = "0.8.0", .. }
+ esp-storage = { version = "0.9", .. }
- esp-alloc = { version = "0.9.0" }
+ esp-alloc = { version = "0.10" }
- esp-println = { version = "0.16.0", .. }
+ esp-println = { version = "0.17", .. }
- esp-bootloader-esp-idf = { version = "0.4", .. }
+ esp-bootloader-esp-idf = { version = "0.5", .. }
- rand_core = { version = "0.6", default-features = false }
- static_cell = "2"

keyboard.toml Update

[layout] and the new [keymap]

The layer definitions move out of [layout] into the new [keymap] section, and matrix_map is renamed to map:

[layout]
rows = 2
cols = 3
- layers = 2
- matrix_map = """
+ map = """
(0,0) (0,1) (0,2)
(1,0) (1,1) (1,2)
"""

- [[layer]]
+ [keymap]
+ layers = 2
+
+ [[keymap.layer]]
name = "base"
keys = """
A B C
D E F
"""

The keymap is also checked more strictly than in v0.8:

  • Every layer's keys must list exactly one action per position in [layout].map. v0.8 silently padded a short layer with No; v0.9 fails the build. Use _ for transparent keys.
  • Layer numbers in key actions (MO(2), LT(2, ...), TG(2), ...) must be smaller than [keymap].layers. A config with layers = 2 that referenced layer 2 built in v0.8 but fails now — raise layers.
  • // comments are no longer allowed inside the map and keys strings. Move them to # comment lines outside the string.

Other layout changes:

  • If you used the 3D-array form (keymap = [[[...]]] without matrix_map), you must now author a [layout].map listing the (row,col) position of every key in visual order, then move each layer of the array into a [[keymap.layer]] keys string.
  • [layout].encoder_map is replaced by encoders inside each [[keymap.layer]].

See Layout for the full syntax.

[rmk] channel options move to [event]

The global channel knobs are replaced by per-event settings:

[rmk]
- event_channel_size = 32
- controller_channel_size = 16
- controller_channel_pubs = 1
- controller_channel_subs = 4

+ [event.keyboard]
+ channel_size = 32

See Event Channels for the available event names and defaults.

[ble] update

ble_use_2m_phy is renamed to use_2m_phy:

[ble]
- ble_use_2m_phy = true
+ use_2m_phy = true

[behavior] update

Morse/tap-hold hold_timeout and gap_timeout values above 8191 ms now fail the build, both in [behavior.morse] and in every named profile under [behavior.morse.profiles] — lower them to 8191 ms or less.

Stricter validation

keyboard.toml is now checked up front at build time, with a clear error instead of a silently ignored key or an obscure compile error further down. Expect a build failure for:

  • Unknown keys in any section — leftovers from the old schema are flagged for you.
  • A row_pins / col_pins / direct_pins count that doesn't match rows / cols — in [matrix], [split.central], and each [[split.peripheral]].
  • A split board whose rows/cols plus row_offset/col_offset exceed [layout], or that overlaps another board.
  • ble_addr on a connection = "serial" split, or serial on a connection = "ble" split.
  • More than 4 [host].unlock_keys, or an unlock key outside [layout].

Rust API Update

Every example's main() changed in v0.9 — the fastest migration is to port your customizations into the v0.9 example for your chip under examples/use_rust. The core change: run_rmk, run_devices!, and EVENT_CHANNEL are gone, every component is a runnable passed to one run_all!, and the USB/BLE plumbing becomes an explicit transport:

- use rmk::channel::EVENT_CHANNEL;
- use rmk::futures::future::join3;
- use rmk::{initialize_keymap_and_storage, run_devices, run_rmk};
+ use rmk::host::HostService;
+ use rmk::usb::UsbTransport;
+ use rmk::{KeymapData, initialize_keymap_and_storage, run_all};

  let keyboard_device_config = DeviceConfig {
      vid: 0x4c4b,
      pid: 0x4643,
      manufacturer: "Haobo",
      product_name: "RMK Keyboard",
-     serial_number: "vial:f64c2b3c:000001",
+     ..DeviceConfig::default()
  };

- let mut default_keymap = keymap::get_default_keymap();
+ let mut keymap_data = KeymapData::new(keymap::get_default_keymap());
  let (keymap, mut storage) = initialize_keymap_and_storage(
-     &mut default_keymap,
+     &mut keymap_data,
      flash,
      &storage_config,
      &mut behavior_config,
-     &mut per_key_config,
+     &per_key_config,
  )
  .await;

+ let host_service = HostService::new(&keymap, &rmk_config);
+ let mut usb_transport =
+     UsbTransport::new(driver, rmk_config.device_config).with_host_service(&host_service);

- join3(
-     run_devices!((matrix) => EVENT_CHANNEL),
-     keyboard.run(),
-     run_rmk(&keymap, driver, &mut storage, rmk_config),
- )
- .await;
+ run_all!(matrix, storage, usb_transport, keyboard).await;

DeviceConfig::default() now fills in a serial number that embeds the RMK version, so the hard-coded serial_number can go. HAL-level changes that come with the embassy upgrade (embassy-rp 0.10's Flash::new takes an Irqs binding, embassy-executor 0.10 spawns with spawner.spawn(task).unwrap()) are not RMK changes — take them from the example.

For rotary encoders, use KeymapData::new_with_encoder(keymap, encoder_map) — the separate initialize_encoder_keymap_and_storage is removed.

For BLE, the transport owns the BLE stack: hand it the controller and address instead of building a stack yourself. HostResources and the ChaCha RNG disappear from your main. BleTransport::new consumes rmk_config, so construct UsbTransport first:

- use rmk::ble::build_ble_stack;
+ use rmk::ble::BleTransport;

- let mut rng_gen = ChaCha12Rng::from_rng(&mut rng).unwrap();
- let mut host_resources = HostResources::new();
- let stack = build_ble_stack(sdc, ble_addr(), &mut rng_gen, &mut host_resources).await;
+ let mut ble_transport = BleTransport::new(sdc, ble_addr(), rmk_config).with_host_service(&host_service);

Split keyboards

On the peripheral, run_rmk_split_peripheral takes the controller and address too, and no longer takes storage — pass the peripheral's storage to run_all! instead. The storage constructor is renamed:

- use rmk::storage::new_storage_for_split_peripheral;
+ use rmk::storage::new_storage_without_keymap;

- let mut storage = new_storage_for_split_peripheral(flash, storage_config).await;
+ let mut storage = new_storage_without_keymap(flash, storage_config).await;

- run_rmk_split_peripheral(0, &stack, &mut storage).await;
+ join(run_all!(matrix, storage), run_rmk_split_peripheral(0, sdc, ble_addr())).await;

On a BLE central, the BLE transport is the split central: BleTransport::new takes a fourth argument describing each peripheral's matrix, and run_peripheral_manager, scan_peripherals, read_peripheral_addresses, and OffsetMatrixWrapper are gone:

- use rmk::matrix::{Matrix, OffsetMatrixWrapper};
+ use rmk::matrix::Matrix;
- use rmk::split::ble::central::{read_peripheral_addresses, scan_peripherals};
- use rmk::split::central::run_peripheral_manager;
+ use rmk::split::PeripheralMatrixConfig;

- let mut matrix = OffsetMatrixWrapper::<_, _, _, 0, 0>(Matrix::<_, _, _, 4, 7, true>::new(row_pins, col_pins, debouncer));
+ let mut matrix = Matrix::<_, _, _, 4, 7, true>::new(row_pins, col_pins, debouncer);
- let peripheral_addrs = read_peripheral_addresses::<1, _, 8, 7, 4, 2>(&mut storage).await;
+ let mut ble_transport = BleTransport::new(
+     sdc,
+     ble_addr(),
+     rmk_config,
+     [PeripheralMatrixConfig { rows: 4, cols: 7, row_offset: 4, col_offset: 0 }],
+ )
+ .with_host_service(&host_service);

- join4(
-     run_peripheral_manager::<4, 7, 4, 0, _>(0, &peripheral_addrs, &stack),
-     run_rmk(&keymap, driver, &stack, &mut storage, rmk_config),
-     scan_peripherals(&stack, &peripheral_addrs),
-     ...
- )
+ run_all!(matrix, storage, usb_transport, ble_transport, keyboard).await;

On a serial (wired) central, run_peripheral_manager loses its const generics and takes the same config struct:

- run_peripheral_manager::<2, 1, 2, 2, _>(0, uart_receiver),
+ run_peripheral_manager(0, uart_receiver, PeripheralMatrixConfig { rows: 2, cols: 1, row_offset: 2, col_offset: 2 }),

Custom devices, processors, and controllers

The Controller / EventController / InputProcessor traits, run_processor_chain!, and the central Event enum are replaced by the unified event/processor model: define events with #[event] (or #[derive(Event)]), implement input devices with #[input_device(publish = ...)], and handle events in #[processor(subscribe = [...])] processors passed to run_all!. See Input devices and Processors for the new model.

Tap-hold profiles

KeyAction::TapHold now stores a u8 index into behavior.morse.profiles instead of an inline MorseProfile. The default-profile macros (th!/mt!/lt!/tt!) are unchanged. If you use the custom-profile macros (thp!/mtp!/ltp!/ttp!), push the profile into behavior_config.morse.profiles and pass its 0-based index; an index with no entry falls back to the default profile:

- thp!(Space, Backspace, MorseProfile::new(None, None, Some(200), None))
+ // in main(): behavior_config.morse.profiles.push(MorseProfile::new(None, None, Some(200), None)).unwrap();
+ thp!(Space, Backspace, 0)

MorseProfile itself is now packed into a u64 instead of a u32 (to make room for quick_tap_timeout); code that relied on its raw u32 representation must be updated.

Renamed and moved APIs

v0.8v0.9
PollingController with const INTERVALPollingProcessor with fn interval()
Pmw3610Device::new(spi, cs, motion, config)PointingDevice::new(id, spi, cs, motion, config)
Pmw3610Processor::new(&keymap)PointingProcessor::new(&keymap, PointingProcessorConfig) (device id lives in the config, default = all devices)
MouseKeyConfig::time_to_max / wheel_time_to_max / wheel_max_speed_multiplierticks_to_max / wheel_ticks_to_max / wheel_max_speed
KeyAction::TapHold(tap, hold)KeyAction::TapHold(tap, hold, profile_index) (u8::MAX = default profile)
rmk::input_device::Runnablermk::core_traits::Runnable (async fn run(&mut self) -> !)
rmk::combo::Combormk::keyboard::combo::Combo
rmk::fork::* / rmk::morse::*rmk::types::fork::* / rmk::types::morse::*
rmk::direct_pin::DirectPinMatrixrmk::matrix::direct_pin::DirectPinMatrix
rmk::controller::* built-insrmk::processor::builtin::* (e.g. KeyboardIndicatorProcessor)

Also: NrfAdc::new gains an event_device_ids array argument, and BatteryProcessor::new drops its &keymap argument.

BLE hosts must re-pair

The HID report ids are renumbered in v0.9 (Keyboard=1, Mouse=2, Media=3, System=4), which changes the BLE report map. USB hosts re-read the descriptor on every enumeration, so nothing changes for them, but a BLE host bonded to a pre-v0.9 firmware caches the old report map — forget the keyboard on the host and pair again after flashing v0.9.

Watchdog

v0.9 enables the hardware watchdog by default on RP2040, nRF52, and ESP32 (a no-op on other chips). keyboard.toml users get it automatically as long as the watchdog feature is on (it is a default feature; see the Cargo.toml update if you use default-features = false). Rust API users construct a runner and pass it to run_all!:

// RP2040
let mut watchdog_runner = Rp2040Watchdog::default_runner(embassy_rp::watchdog::Watchdog::new(p.WATCHDOG));
// nRF52
let mut watchdog_runner = Nrf52Watchdog::default_runner(p.WDT);

run_all!(matrix, storage, usb_transport, keyboard, watchdog_runner).await;

ESP32 has no default_runner: configure and enable a timer-group watchdog yourself, wrap it in Esp32Watchdog::new, and pass it to WatchdogRunner::new with the feed interval.

To disable the watchdog, set default-features = false and list your features without watchdog. See Watchdog for details.

Experimental features

Two v0.9 features are experimental: they are opt-in, off by default, and nothing in this guide depends on them. Their configuration and APIs can change in any later release without a migration guide, so skip this section unless you want to try them.

  • Rynk — RMK's native host protocol, an alternative to Vial (they are mutually exclusive, so enable exactly one). To try it, set [host] rynk_enabled = true / vial_enabled = false in keyboard.toml, and in Cargo.toml set default-features = false and list your features with rynk instead of vial (vial is a default feature, so it can't be swapped out otherwise). Vial stays the default and needs no changes.
  • DFU firmware update — update firmware over USB with the rmk-boot bootloader, via the dfu_rp / dfu_nrf features. It repartitions your flash, so read the bootloader docs before enabling it on a board you rely on.