0. はじめに
無線キーボードを作成するときのファームウェアとして、第一候補にZMKを選択するキーボード設計者は多いと思う。ZMK対応のマイコンボードのうち、国内で使用できる、いわゆる技適に登録されているのは(筆者の知る限り) Seeed Studio の XIAO-nRF52840(以降 Xiao BLE とする)のみだが、GPIO数が11個と少なく、製作できるキーボードに制限があるように思っていた...
本投稿は、ZMKとXiao BLE を限界まで使い、デュアルトラックボール+ロータリーエンコーダ付き一体型キーボード(40%+)を作成するにあたって調べたことや考えたことを備忘録として残しておく。
XIAO nRF52840 を限界まで使った習作(地球、太陽、月のモチーフ)
— アラサーBP (@BParound30) March 9, 2025
Keyboard: OnCoroCoro
Switch: Kailh Black Cloud
Keycap: 3Dプリント#KEEB_PD #KEEB_PD_R240 #自作キーボード pic.twitter.com/4xvZLdlWNG
ピスタチオ!
— アラサーBP (@BParound30) March 16, 2025
Keyboard: OnCoroCoro
Switch: Kailh ALL-POM WHITE RAIN
Keycap: 3Dプリントで自作#KEEB_PD #KEEB_PD_R241 #自作キーボード pic.twitter.com/GAOJBwmVRe
1. ハードウェア
1.0 トラックボールの操作方法
トラックボールの操作には大きく分けて親指型か手のひら型(人差し指型)の2種類あるが、筆者は手のひら型が好みなので、本記事は特に断りのない限り、手のひら型トラックボールつきキーボードに話題をフォーカスしている。
1.1 光学センサ
トラックボールの読み取りには、PixartのPMW3610光学センサを使用する。専用のブレークアウトボードがオープンソース化されているのでそれを使うか、PCBに直接組み込んでもよい(PCBA推奨)。
QMKで採用実績のあるPMW3360とは別物なので注意。
特に、キーボードの高さを抑えたい場合、PCBより下にブレークアウトボードを配置する必要があるが、光学センサのレンズとの干渉を避けるようにPCBに穴をあけておくこと。
親指型トラックボールの場合は、PCBに垂直にブレークアウトボードを立てる方法が主流のようだ。
1.2 ボールとセンサーの位置
PixartのPMW3610のレンズから読み取り平面までの推奨距離は2.4mmとなっている(参照:PMW3610-SUDU データシート)。この光学距離もPCB設計時にある程度考慮に入れておくとよい。
なお、トラックボールの場合、読み取り曲面となるため、筆者は0.1mm近づけて2.3mmなども試してみたが、大きな違いは感じなかった。この辺りの調整はボールの材料にもよるかもしれない。
1.3 ボールのサイズ
手のひら型のトラックボールの場合、PCBとセンサーのブレークアウトボードは平行に配置するのが基本となるが、開発中に感じたメリットは、ボールのサイズを変更してもボールの中心位置が上下左右で変わらないということだ(高さ方向のみ変わる)。どのボールサイズが快適かをいろいろ試すのにはとても手軽だった。
直径30mmの球を入手したので換装してみました
— アラサーBP (@BParound30) January 2, 2025
34mm(左下)の操作性(=ポインタを大きく動かしやすく、止めたいところでブレにくい)と25mm(右下)の収納性(=手の中の空間に収まりがよくタイピング時に干渉しにくい)を両立する大きさです
しばらくこの大きさで運用してみます#自作キーボード https://t.co/RgHHZGc75Y pic.twitter.com/ioZTQ4fyad
ボールの大きさや重さが操作性に影響するのは直感的に理解できるかもしれないが、それぞれ独自のパラメータとしてではなく、慣性モーメントとして1つの指標で比較してみるのも面白いと思った。
$$ I = \frac{2}{5} M R^2 $$
$I$ : 慣性モーメント(イナーシャ), $M$ : 質量, $R$ : 半径
1.4 デュアルトラックボール
PMW3610ドライバはSPI通信であるが、追いボールしたい場合、追加で2つのGPIOが必要(cs-gpios と irq-gpios)なので、PCB設計時に確保しておくこと。これはSPIのスレーブ切り替え(Chip Select)のほかに、省電力のための割り込みフラグ(Interrupt Request)が必要であるからと推測する。
1.5 Charlieplexing
Charlieplexingは少ないGPIOで多くのキーを認識するためのキースキャン方式(参照:Charlieplex Driver)。N個のGPIOで(N-1)*(N-2)のキー数を実現できる。トラックボール用にGPIOが4つ(デュアルトラックボールの場合6つ)必要なので、40%一体型のキー数をまかなうにはこのスキャン方式以外に選択がない。
このスキャン方式には通常のマトリクススキャンよりも多くのダイオードを必要とするため、これもPCB設計時に反映しておく必要がある。Charliplexingのための配線図もオープンソースのものがあるので、筆者はそれを利用した。
省電力性能を犠牲にして、割り込み用のGPIO(interrupt-gpios)をなくすわかりに、N*(N-1)のキー数も実現可能。ただし、筆者にとってはバッテリー残量を確認する頻度が多くなるのはストレスだったので不採用とした。
1.6 隠しIO
XIAO BLEは基本的に、下図のD0~D10のようにGPIOが11個しかない。
と思っていたのだが、実はそれ以外にも使える隠しIOが5つある。
1か所目は下のように、背面のNFCパッドを利用した2ピン。PCB設計時は、このパッドにはんだを流し込めるように、スルーホールを開けておく。
もう1か所は、表面のXIAO nRF52840 Scene向けに用意された、表面実装用の3ピン。
XIAO BLEを限界まで使う場合、この3ピンの空中配線は不可避だが、パッド付近のPCBにブレークアウト用の穴を用意しておくと便利(下図参考)。
1.7 作れるキーボード
以上を踏まえ、すべてのIOを用い、キースキャンはCharlieplex方式とすると、次のようなトラックボール付きキーボードが作れる。
- フルサイズ(MAX:110キー)キーボード + 1トラックボール
- フルサイズ(MAX:110キー)キーボード + 2エンコーダ
- 70%(MAX:72キー)キーボード + 2トラックボール
- 70%(MAX:72キー)キーボード + 1トラックボール + 1エンコーダ
- 40%(MAX:42キー)キーボード + 2トラックボール + 1エンコーダ
- 40%(MAX:42キー)キーボード + 1トラックボール + 2エンコーダ
2. ファームウェア
2.0 ZMK
タイトルにある通り、ZMKファームウェアを使う。ZMKファームウェアのビルドにはGitHub Actionsとローカルビルドがあるが、筆者はGitHub Actionsでビルドしている。
細かい設定方法は公式ドキュメントやDiscordの他、YouTubeなどが参考になる。
2.1 隠しIOへのアクセス
通常のGPIOには次のようにアクセスできる。
D2ピン → &xiao_d 2
GPIOには固有のピン番号が割り当てられており、次のようにアクセスすることもできる。
D2ピン=P0.28 → &gpio0 28
同様に、隠しIOへも次のようにアクセスできる。
- P0.09 → &gpio0 9
- P0.10 → &gpio0 10
- P0.16 → &gpio0 16
- P1.00 → &gpio1 0
- P1.10 → &gpio1 10
ただし、NFCパッド(P0.09,P0.10)をIOとして使うには、your_keyboard.conf
にてCONFIG_NFCT_PINS_AS_GPIOS=y
を設定しておく。
2.2 Charlieplexの設定
公式ドキュメント通りに設定する。
2.2 トラックボールの設定
ZMKのモジュール(参照:ZMK Modules)としてドライバが公開されているので、ファームウェアに簡単に組み込める。
筆者はbadjeff氏のドライバを使用している。inorichi氏のドライバでは、後述のデュアルトラックボールの際CPIを個別に設定できなことが大きな理由である。
上記リポジトリのREADMEどおりに設定すればよい。
2.3 デュアルトラックボールの設定
badjeff氏のドライバの設定に2つ目のトラックボールの設定例を書き加える。
&pinctrl {
spi0_default: spi0_default {
group1 {
psels = <NRF_PSEL(SPIM_SCK, 1, 13)>,
<NRF_PSEL(SPIM_MOSI, 1, 15)>,
<NRF_PSEL(SPIM_MISO, 1, 15)>;
};
};
spi0_sleep: spi0_sleep {
group1 {
psels = <NRF_PSEL(SPIM_SCK, 1, 13)>,
<NRF_PSEL(SPIM_MOSI, 1, 15)>,
<NRF_PSEL(SPIM_MISO, 1, 15)>;
low-power-enable;
};
};
};
#include <zephyr/dt-bindings/input/input-event-codes.h>
&spi0 {
status = "okay";
compatible = "nordic,nrf-spim";
pinctrl-0 = <&spi0_default>;
pinctrl-1 = <&spi0_sleep>;
pinctrl-names = "default", "sleep";
cs-gpios = <&gpio1 11 GPIO_ACTIVE_LOW>, <&gpio1 12 GPIO_ACTIVE_LOW>; // ここに2つCSを設定
trackball: trackball@0 {
status = "okay";
compatible = "pixart,pmw3610";
reg = <0>;
spi-max-frequency = <2000000>;
irq-gpios = <&gpio0 6 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
cpi = <600>;
evt-type = <INPUT_EV_REL>;
x-input-code = <INPUT_REL_X>;
y-input-code = <INPUT_REL_Y>;
};
// 2つめのトラックボールを設定
trackball2: trackball2@1 {
status = "okay";
compatible = "pixart,pmw3610";
reg = <1>;
spi-max-frequency = <2000000>;
irq-gpios = <&gpio0 7 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
cpi = <300>;
evt-type = <INPUT_EV_REL>;
x-input-code = <INPUT_REL_X>;
y-input-code = <INPUT_REL_Y>;
};
};
/ {
trackball_listener {
compatible = "zmk,input-listener";
device = <&trackball>;
};
// 2つ目のトラックボールリスナーを追加
trackball_listener2 {
compatible = "zmk,input-listener";
device = <&trackball2>;
// これ以降にリスナーの設定
// スクロールレイヤーの例
scroll {
layers = <DEFAULT>;
input-processors = <&zip_xy_transform (INPUT_TRANSFORM_Y_INVERT)>, <&zip_xy_transform (INPUT_TRANSFORM_X_INVERT)>, <&zip_xy_scaler 1 20>, <&zip_xy_to_scroll_mapper>;
process-next;
};
};
};
2.4 ロータリーエンコーダの設定
ロータリーエンコーダは連続した入力をするのに便利。基本的な設定は公式ドキュメント参照。
レイヤーごとに挙動を変えることもできるが、エンコーダのために別途レイヤーを用意するのも(個人的に)面倒だったので、Mod Morphを活用する方法を紹介する。
同一レイヤーにて以下の設定のデモとyour_keyboard.keymap
の設定例
- エンコーダ単体:音量下げる/上げる
- Shift+エンコーダ:矢印キー左/右
- Ctrl+エンコーダ:矢印キー上/下
- Alt+エンコーダ:Alt+Shift+Tab/Alt+Tab
- 前のアプリ/次のアプリにフォーカス
- Win+エンコーダ:Win+左/Win+右
- ウィンドウを左に移動/ウィンドウを右に移動
レイヤーを変えずにModMorphでエンコーダ機能を変化させるデモ
— アラサーBP (@BParound30) March 16, 2025
- エンコーダ単体:音量下げる/上げる
- Shift+エンコーダ:矢印キー左/右
- Ctrl+エンコーダ:矢印キー上/下
- Alt+エンコーダ:Alt+Shift+Tab/Alt+Tab
- Win+エンコーダ:Win+左/Win+右#自作キーボード #ZMK pic.twitter.com/lhbZMxRc7G
behaviors {
alt_p: alt-positive {
compatible = "zmk,behavior-mod-morph";
#binding-cells = <0>;
bindings = <&kp 0>, <&kp TAB>;
mods = <(MOD_LALT|MOD_RALT)>;
keep-mods = <(MOD_LALT)>;
};
gui_p: gui-positive {
compatible = "zmk,behavior-mod-morph";
#binding-cells = <0>;
bindings = <&kp 0>, <&kp RIGHT>;
mods = <(MOD_LGUI|MOD_RGUI)>;
keep-mods = <(MOD_LGUI)>;
};
ag_p: alt-gui-positive {
compatible = "zmk,behavior-mod-morph";
#binding-cells = <0>;
bindings = <&gui_p>, <&alt_p>;
mods = <(MOD_LALT|MOD_RALT)>;
};
arrow_p: arrow-positive {
compatible = "zmk,behavior-mod-morph";
#binding-cells = <0>;
bindings = <&kp RIGHT>, <&kp DOWN>;
mods = <(MOD_LCTL|MOD_RCTL)>;
};
vol_arrow_p: volume-arrow-positive {
compatible = "zmk,behavior-mod-morph";
#binding-cells = <0>;
bindings = <&kp C_VOL_UP>, <&arrow_p>;
mods = <(MOD_LSFT|MOD_RSFT|MOD_LCTL|MOD_RCTL)>;
};
vol_alt_gui_p: volume-alt-gui-positive {
compatible = "zmk,behavior-mod-morph";
#binding-cells = <0>;
bindings = <&vol_arrow_p>, <&ag_p>;
mods = <(MOD_LALT|MOD_RALT|MOD_LGUI|MOD_RGUI)>;
};
alt_n: alt-negative {
compatible = "zmk,behavior-mod-morph";
#binding-cells = <0>;
bindings = <&kp 0>, <&kp LS(TAB)>;
mods = <(MOD_LALT|MOD_RALT)>;
keep-mods = <(MOD_LALT)>;
};
gui_n: gui-negative {
compatible = "zmk,behavior-mod-morph";
#binding-cells = <0>;
bindings = <&kp 0>, <&kp LEFT>;
mods = <(MOD_LGUI|MOD_RGUI)>;
keep-mods = <(MOD_LGUI)>;
};
ag_n: alt-gui-negative {
compatible = "zmk,behavior-mod-morph";
#binding-cells = <0>;
bindings = <&gui_n>, <&alt_n>;
mods = <(MOD_LALT|MOD_RALT)>;
};
arrow_n: arrow-negative {
compatible = "zmk,behavior-mod-morph";
#binding-cells = <0>;
bindings = <&kp LEFT>, <&kp UP>;
mods = <(MOD_LCTL|MOD_RCTL)>;
};
vol_arrow_n: volume-arrow-negative {
compatible = "zmk,behavior-mod-morph";
#binding-cells = <0>;
bindings = <&kp C_VOL_DN>, <&arrow_n>;
mods = <(MOD_LSFT|MOD_RSFT|MOD_LCTL|MOD_RCTL)>;
};
vol_alt_gui_n: volume-right-alt-gui-negative {
compatible = "zmk,behavior-mod-morph";
#binding-cells = <0>;
bindings = <&vol_arrow_n>, <&ag_n>;
mods = <(MOD_LALT|MOD_RALT|MOD_LGUI|MOD_RGUI)>;
};
vol_alt_tab: volume-alt-tab {
compatible = "zmk,behavior-sensor-rotate";
#sensor-binding-cells = <0>;
bindings = <&vol_alt_gui_n>, <&vol_alt_gui_p>;
tap-ms = <10>;
};
};
2.5 エンコーダでスクロール
デュアルトラックボールで1つのボールはスクロールに割り当てれば、エンコーダでスクロールする必要はないと思っていたが、縦にのみスクロールしたい場合も出てきた。この場合、
- レイヤーによって、縦スクロールのみ有効にする設定をする
- エンコーダで縦スクロールする
- キー押下をスクロールに割り当てる
という選択肢があるが、エンコーダで縦スクロールする場合はyour_keyboard.keymap
に以下のように設定する。
#define ZMK_POINTING_DEFAULT_SCRL_VAL 120 // (好きな値)
#include <dt-bindings/zmk/pointing.h>
(略)
/ {
(略)
behaviors {
(他の設定)
mouse_scrl: mouse_wheel_scrl {
compatible = "zmk,behavior-sensor-rotate-var";
#sensor-binding-cells = <2>;
bindings = <&msc>, <&msc>;
tap-ms = <10>;
};
};
keymap {
default_layer {
(略)
sensor-bindings = <&mouse_scrl SCRL_UP SCRL_DOWN>;
};
};
}
筆者は #define ZMK_POINTING_DEFAULT_SCRL_VAL 120
の設定が必要なことを見落としており、スクロールできずに半日スタックしていた…
おわりに
今まで調べたことを思い出しながらちょっとずつ書いたので、メモの寄せ集めのような内容が体系的でない記事になってしまったが、とりあえず調べたことを1か所にまとめたかったという最低限の目標は達成できたと思う。
今後も何か思い出したら追記するかもしれないし、別記事にするかもしれないが、同じような寄せ集め記事になりそうだ。それでも少しでも誰かの役に立てれば光栄である。
参考リンク
PMW3610ドライバ
- https://github.com/badjeff/zmk-pmw3610-driver (本記事で使用)
- https://github.com/inorichi/zmk-pmw3610-driver
PMW3610ブレークアウトボード
- https://github.com/siderakb/pmw3610-pcb (本記事で使用)
- https://github.com/ufan/pmw3610_breakout
- https://github.com/victorlucachi/charybdis-pmw3610-breakout