16 November 2021

I built a wireless split keyboard

I recently built a compact split keyboard that works over Bluetooth.

Rico Sta. Cruz @rstacruz

I recently built a compact split keyboard that works over Bluetooth. It’s pretty nice! Let me share with you some random tidbits I learned along the way about building keyboard firmware with ZMK.

Picture of a split keyboard
The keyboard is built using the Microdox kit from Boardsource. It uses a nice!nano v2 microcontroller on each half.

What is it for?

I’ve been using wired compact split keyboards for a year now on my workstation. It’s great with my laptops, but there are a few things on my feature wishlist. I wanted to have:

While I could have went with a pre-built keyboard, I didn’t want to compromise on having my custom compact split layout. I’ve decided that the answer to this list is to build a wireless, portable version of my main keyboard.

The right half of a Corne keyboard
The main keyboard I’ve been using: a 5-column Corne keyboard with a custom acrylic case. Unlike the Microdox build, this doesn’t support Bluetooth.

How I make 36 keys work

I’ve been working on a keymapping that allows me to have all keys of a full-sized keyboard in just 36 keys. There’s a lot to talk about here and I’d love to write about it in a future post, but here’s the basics:

Reference of a custom keymap

Semi-wireless on the desktop

My favourite way of using the keyboard is in what I’d call semi-wireless mode, with the left half being wired to the laptop, and the right half being wireless.

This allows me to overcome Bluetooth flakiness and latency. Connection between the two halves is very consistent and reliable, while some laptops and tablets can sometimes be a hit-or-miss (more on this later).

Picture of a split keyboard
Microdox wireless split keyboard with a Logitech G304 mouse. Bonus: this charges the battery of the left half as well.

ZMK firmware

Most keyboards are built using QMK firmware, but I chose to use the ZMK firmware for my keyboard. ZMK supports wireless keyboards, while QMK doesn’t.

I had to port over my QMK keymap to ZMK by hand. I personally found ZMK quite easy to get started with, and didn’t have too much trouble with it.

Bluetooth reliability

Latency and reliability often depends per device. Here’s what I’ve been using it on:

Pairing between halves

Connection between both halves of the split keyboard is very solid. Here’s how it works:

Pairing with devices

ZMK keyboards can pair with multiple devices using profiles. Here’s what I’ve observed so far on how ZMK handles profiles:

Caveat: phone on-screen keyboards

The keyboard stays connected to devices, even phones. Having a keyboard connected might mean that on-screen keyboards won’t appear. On Android, it’s not much of an issue because double-tapping on a text field will force on-screen keyboards to appear.

Caveat: disconnecting

If the device is manually disconnected from the phone’s Bluetooth menu, it will not reconnect. The only way to reconnect it is to connect it again from the phone’s Bluetooth menu.

Eager debouncing

Debouncing is a feature of keyboards used to prevent double-presses common with imperfect soldering. Both ZMK (v2.5 on Nov 2021) and QMK have debouncing off by default, which adds a latency of 5ms per keypress.

I found that it’s best to turn this feature on. ZMK also supports eager-debouncing which minimises the latency down to 1ms.

# Debounce settings
# https://zmk.dev/docs/features/debouncing

Lock layer

I implemented a “lock keyboard” feature using layers. I found that it’s great to implement a way to “lock” the keys from being pressed. It can be useful for:

lock_layer { // Lock
  bindings = <
    &mo ULOC  &none     &none     &none     &none        &none     &none     &none     &none     &none
    &none     &none     &none     &none     &none        &none     &none     &none     &none     &none
    &none     &none     &none     &none     &none        &none     &none     &none     &none     &none
                        &none     &none     &mo ULOC     &none     &none     &none
unlock_layer { // Unlock
  bindings = <
    &tog LOCK &none     &none     &none     &none        &none     &none     &none     &none     &none
    &none     &none     &none     &none     &none        &none     &none     &none     &none     &none
    &none     &none     &none     &none     &none        &none     &none     &none     &none     &none
                        &none     &none     &tog LOCK    &none     &none     &none

The “lock” layer has all keys set to &none, and is turned on using &tog LOCK. I also added a way to exit this mode with two keypress. Toggling this layer will “lock” the keyboard.

Sleep mode

There is an undocumented option to enable sleep mode called CONFIG_ZMK_SLEEP. This will turn off the device after not being used for a period of time. Caveat: it will take around 5 seconds to reconnect after waking up.

In the end, I turned off sleep mode. 5 seconds to wake up was too long. Since I use the keyboard semi-wirelessly around 75% of the time, I never need to worry about charging.

# 2700000 = 45mins
# 3600000 = 1hr
The setting CONFIG_ZMK_SLEEP is undocumented, but you can verify its existence with the GitHub repos that use it.

Battery life

I used a 110mAh battery, which is the smallest battery you can get away with on the nice!nano. I’ve heard from others making builds with up to 3,000mAh, but given I was using this wired from time to time, I’m happy with a small battery.

The left half drains by around 8% everyday. The right half lasts forever. This is probably 1 to 2 weeks of use before needing a charge on the left. The ZMK power profiler estimates that the right half will last up to 2 months of daily use.

Photo of a small battery labeled 301230 110mAh

Soldered on battery

I initially thought of adding an on/off power switch, but I opted to solder the battery directly to the keyboard.

There are two reasons I thought I’d need a physical switch for: to save battery life, and to prevent keypresses when the keyboard is stored away. ZMK’s sleep functionality can take care of the first, while a lock layer can take care of the second.

This means there’s no way to turn the keyboard completely off, and I found that it’s okay. Our phones work the same way afterall!

Getting battery info

Getting the battery percentage is tricky without an OLED screen. The battery meter for the left half is reported on these platforms over Bluetooth: Linux (via upower -d), ChromeOS, MacOS (Monterey).

I’ve found that is not reported on these platforms: Windows, MacOS Big Sur, Android.

Replaced my nice!nano

I ended up having to buy another pair of nice!nano v2 micro-controllers. I encountered some issues with the original ones I had:

Reset button

There is a hardware reset button on my build. I’d press this once to reboot the firmware, or twice to enter bootloader mode to allow reflashing.

This might be specific to my build, but I find that sometimes I need to press the hardware reset button after leaving the keyboard alone for a while. Not sure if this is because the firmware crashed, or I have some shoddy soldering somewhere.

Some other notes

Some more random bullet points:

Thanks for reading! I'm Rico Sta Cruz, I write about web development and more. Subscribe to my newsletter!