IO Expander MCP23017で分割したキーボード(Sparrow62v2) でQMK Firmwareに対応させる

tl;dr

  • MCP23017は、I2C IOエキスパンダーで、IOが足りないない所をI2Cプロトコルで拡張できるICだよ。16bitのIOが増やせるよ。ただし、プルダウン機能はないよ。
  • Sparrow62v2では左手側にRaspberry Pi Picoを積み、右手側にMCP23017を積み、その間をI2Cで接続したキーボードだよ。今まで、KMK Firmwareしか対応してなかったよ。
  • MCP23017で利用したプロトコルは、INPUT/OUTPUT方向を決めるIODIRA, IODIRBと、値のゲットセットを行うGPIOA、GPIOBさえ使えば良いよ。
  • QMK Firmwareで、scan_custom_matrix()を実装することで、MCP23017でのマトリックススキャンを実装したよ。

Sparrow62v2

Sparrow62v2は、私の作っている左右分割型自作キーボードキットです。かつては游舎工房でも委託販売しておりましたが、現在は在庫切れとなっております。そろそろv3を作りたいと思っており、再販予定は今のところありません。

左右分割で左右でレイアウトが異なっているのと、トッププレート、ボトムプレート、メインボードも厚さがそれぞれ異なるPCBを利用しているため、結構生産にコストがかかります。1年半で20程度販売できたのですが、再生産コストをかけるならばv3を設計したい気持ちがあり、v2の再生産を予定していません。

一方、利用いただいてる方はいらっしゃり、QMK Firmware対応のリクエストを受けました。MCP23017を利用しており、QMK Firmwareの基本機能に習熟していないこともあり、販売当初からKMK Firmwareのみをサポートさせてもらっていました。SparrowDialを通して、QMK Firmwareについて理解を深め、I2Cデバイスを使うための機能が整備されていることを理解したため、対応をやってみることにしました。

IOエキスパンダーMCP23017を組み込む

IOエキスパンダーMCP23017は、I2Cのシリアル通信で、GPIO 16bit分を操作できるICです。秋月でも190円で購入することができます。半導体品不足の時には、在庫切れを起こしていましたが、現状在庫豊富なようです。

akizukidenshi.com

Sparrow62v2ではスペースを節約したり、MCUを2個用意させるコストに疑問を感じていたため、IOエキスパンダーを利用するようにしました。

ただ、注意点としては、MCP23017には入力に対してプルアップ(フローティング時でHighとする)する機能はあるのですが、プルダウン(フローティング時にLowとする)する機能はありません。COLからROWに電流を流すスイッチマトリックス構成の場合、入力側のROWにプルダウンが必要となります。そのため、プルダウン抵抗を回路に追加しました。

実際のSparrow62v2の右手側MCP23017周りの回路図

Sparrow62v2の構成

Sparrow62v2の内部構成はざっくり以下のような構成です。

左手メインボードではRP2040(Raspberry Pi Pico)上でQMK Firmwareが動作しており、TRRSソケットを通して右手メインボードのMCP23017のI2Cで通信を行っています。

左右のメインボードでそれぞれスイッチマトリックスが組まれており、COLからROWに電流が流れるようになっています。スイッチマトリックスがRP2040、MCP23017に接続された形になっています。

MCP23017のプロトコル

I2Cではマスター(MCU、RP2040)側から、スレイブ(MCP23017)に対して、「特定アドレスのレジスタを読み書きする」ようにプロトコルを組むことが多いです。このレジスタがアドレスごとに機能が異なっており、特定のアドレスで、GPIOの役割をセットできたり、GPIOのアウトプットを設定したり、インプットの値を取得したりします。

MCP23017には、アドレスの振り方に、16ビットモード(IOCON.BANK=0)と、8ビットモード(IOCON.BANK=1)があります。初期状態が16ビットモードになっていて、16ビットモードでもアドレスが違うだけなので、私は16ビットモードで利用しています。本稿も16ビットモードで解説します。

主に使うのは以下の4つレジスタだけです。

名称 レジスタアドレス 機能
IODIRA 0x00 GPIOAの各IO0~8のインプット・アウトプットの方向を8bit(0:アプトプット、1: インプット)で設定する
IODIRB 0x01 GPIOBの各IO0~8のインプット・アウトプットの方向を8bit(0:アプトプット、1: インプット)で設定する
GPIOA 0x12 GPIOAの各IO0~8のインプット・アウトプットの値を8bit(0: Low、1:High)で取得、設定する
GPIOB 0x13 GPIOBの各IO0~8のインプット・アウトプットの値を8bit(0: Low、1:High)で取得、設定する

IODIRAをROWのインプット、IODIRBをCOLのアウトプットとして使うため、以下を設定すれば良いとわかります。

  • GPIOAを全てインプットにするため、IODIRAに0xffをセット
  • GPIOBを全てアウトプットにするため、IODIRBに0x00をセット

そしてCOLからROWのマトリックススキャンをするため、以下を行います。

  • 読み取りたいCOLのGPIOのみをHighにするため、GPIOBに該当bitのみをHigh(例: 0b00000010)をセット
  • COL設定後に、ROWのGPIOを読み取る(対象COLにおいて、スイッチが押されたキーのROWのみがHighになる)ため、GPIOAを読み取り

キーマトリックスの制御については、Google検索すると記事が見つかるため、そちらをご覧ください。

MCP23017については詳しくは、日本語のデータシートがあるので、こちらを参照ください。

MCP23017データシート

https://ww1.microchip.com/downloads/jp/DeviceDoc/20001952C_JP.pdf

QMK Firmwareでのカスタムキーマトリックスをスキャンのインターフェイス

QMK Firmwareにおいては、カスタムのキーマトリックスを実装するための機能が用意されています。このうち、CUSTOM_MATRIX=liteの機能があり、こちらを利用することにしました。

docs.qmk.fm

liteでは以下の関数のみ実装します。

  • void matrix_init_custom(void)
    • キーマトリックの初期化を行う関数
    • MCP23017や、RP2040のGPIOの設定を行う。
  • bool matrix_scan_custom(matrix_row_t current_matrix)
    • キーマトリックスのスキャンを行う関数
    • 引数current_matrix に行ごとの16bitのビット列が渡される。この各行値を、各列のbitを1(押されいる)/0(押されいない)に設定することで、どのキーが押されているかを渡す。
    • 戻り値に、current_matrix[]に変更がある場合、trueを返す。

一点注意点としては、通常はkeyboard.jsonに"matrix_pins"を設定すると思いますが、カスタムマトリックスliteを有効にすると、この値は無視されます。そのため、MCP23017を使わないRP2040側で行うマトリックススキャンも実装する必要があります。

カスタムマトリックスliteと、I2C通信の有効化

カスタムマトリックスlite

カスタムマトリックスを有効にするには、keyboards/{キーボード名}/rules.mkに以下を記述します。

CUSTOM_MATRIX = lite
SRC += matrix.c

keyboard.jsonの"matrix_pins"で通常は生成される、MATRIX_ROWS、MATRIX_COLSの値をkeyboards/{キーボード名}/config.hに設定します。

#pragma once

#define MATRIX_ROWS 5
#define MATRIX_COLS 14

最後に、スキャンの実装を行うkeyboars/{キーボード名}/matrix.cを追加します。

void matrix_init_custom(void) {
}

bool matrix_scan_custom(matrix_row_t current_matrix[]) {
}

I2C通信の有効化

QMK FirmwareにはI2C通信のための機能があり、こちらを有効化します。

くわしくはQMK Firmwareのドキュメントを参照ください。

docs.qmk.fm

keyboard/{キーボード名}/config.hに以下を追加します。RP2040では、I2C0とI2C1の2つがあります。I2C_DRIVERでどちらを利用するのか指定します(下記はI2C0の例)。さらに、I2Cで利用するピンを設定します。I2C0であったとしてもI2C1_SDA/SCL_PINに設定します。F_SCLはI2Cの通信速度ですが、電子工作レベルでは100kHzくらいが使われます。うまく通信ができない場合、この値を落としてみたり、スキャン時の速度が遅い場合は上げてみるのも手です。ただ、MCUによって使える速度の種類に制限があります。

#define I2C1_SCL_PIN GP13
#define I2C1_SDA_PIN GP12
#define I2C_DRIVER I2CD0
#define F_SCL 100000

keyboard/{キーボード名}/halconf.hを作成し、以下を追加します。

#define HAL_USE_I2C TRUE

keyboard/{キーボード名}/mcuconf.hを作成し、以下を追加します。

#pragma once

#include_next <mcuconf.h>

#undef RP_I2C_USE_I2C0
#define RP_I2C_USE_I2C0 TRUE

#undef RP_I2C_USE_I2C1
#define RP_I2C_USE_I2C1 FALSE

デバッグコンソールを有効にする

QMK Firmwareのロジック実装時は、プリント文によるデバッグをできるようにしておきます。

まず、ログを出力するコンソールを有効化するには、keyboard.jsonのfeaturesにconsoleをtrueに設定します。

{
    // 略
    "features": {
        "bootmagic": true,
        "command": false,
        "console": true, // これ!
        "extrakey": true,
        "mousekey": true,
        "nkro": false
    },
    // 略
}

これで、各コードで#include <print.h>をして、printf()を呼ぶとコンソールに出力されるようになります。

デバッグ用のdprintf()も用意されており、デバッグ時はこちらを利用しておけば、デバッグ終了後もprintf()を削除する必要がなくなります。

dprintf()では、keymap.cにて、以下のフックを実装し、デバッグを有効化しておきます。

void keyboard_post_init_user(void) {
  // Customise these values to desired behaviour
  debug_enable = true;
  debug_matrix = false;
  //debug_keyboard=true;
  //debug_mouse=true;
}

このコンソールに接続して見るには、qmkコマンドを使います。

qmk console

MCP23017の初期化を実装する

matrix.cの実装に入っていきます。

ポイントとして以下の部分です。

  • i2cの初期化として、i2c_init();を呼ぶ。
  • i2c_write_register()で、IODIRA、IODIRBで、各GPIOのインプット・アウトぷっとの方向を設定する
#include "timer.h"
#include "matrix.h"
#include "debug.h"
#include "wait.h"
#include <print.h>
#include "platforms/chibios/gpio.h"
#include "i2c_master.h"

#define MCP23017_I2C_ADDRESS 0x20
#define MCP23017_IODIR_A 0x00
#define MCP23017_IODIR_B 0x01

#ifndef MCP23017_I2C_TIMEOUT
#    define MCP23017_I2C_TIMEOUT 100
#endif

void matrix_init_custom(void) {
    i2c_init();
    wait_ms(10);

    // MCU側のキーマトリックスの方向設定
    // 略

    // GPIOAをInputに設定
    uint8_t      iodir_a  = 0xff;
    status = i2c_write_register(MCP23017_I2C_ADDRESS << 1, MCP23017_IODIR_A, &iodir_a, 1, MCP23017_I2C_TIMEOUT);
    dprintf("set I2C IODIR_A i2c_status_t:%d\n", status);

    // GPIOBをOutputに設定
    uint8_t      iodir_b  = 0x00;
    status = i2c_write_register(MCP23017_I2C_ADDRESS << 1, MCP23017_IODIR_B, &iodir_b, 1, MCP23017_I2C_TIMEOUT);
    dprintf("set I2C IODIR_B i2c_status_t:%d\n", status);
}

i2c_write_registerが、レジスタに書き込む関数になります。

MCP23017のI2Cアドレス(同じI2Cバスに繋がった複数のI2Cスレーブデバイスを、マスター側から指示しわけるためのアドレス)が0x20となっています。ただし、i2c_write_registerにおいては1bitシフトして設定する必要があります。

MCP23017からキーマトリックスのスキャンを実装する

COL2ROWのキーマトリックスのスキャンでは以下を行う必要があります。

  1. COL0をHighにし、それ以外のCOLをLowにする
  2. ROWを読み取る
  3. 1〜2を各COLで繰り返す

各COL/ROWを設定するためのbit値を示す配列として、RIGHT_COL_BITS、RIGHT_ROW_BITSを指定しました。COL0のときはRIGHT_COL_BITS[0]を使う形になります。

uint8_t RIGHT_COL_BITS[] = {1, 1 << 1, 1 << 2, 1 << 3, 1 << 4, 1 << 5, 1 << 6, 1 << 7};
uint8_t RIGHT_ROW_BITS[] = {1, 1 << 1, 1 << 2, 1 << 3, 1 << 4};

なお、私のキーボードではRP2040側のキーマトリックスのGPIOの定義もしています。

uint8_t LEFT_COLS[]      = {GP5, GP6, GP7, GP8, GP9, GP10, GP11};
uint8_t LEFT_ROWS[]      = {GP0, GP1, GP2, GP3, GP4};

わかりにくいのですが、QMK Firmwareにとっては以下となるように私のキーボードでは実装することになります。なので、キーボードにとってのCOLと、MCP23017として処理するCOLがずれることに注意してください。

スキャンは以下のように実装しました。RP2040側のキーマトリックスの処理についてはブログ中では省略します。コードは公開していますので、そちらを参照ください。

#define MCP23017_I2C_ADDRESS 0x20
#define MCP23017_GPIO_A 0x12
#define MCP23017_GPIO_B 0x13

bool matrix_scan_custom(matrix_row_t current_matrix[]) {
    // スキャン後のマトリックスの状態を入れておく
    matrix_row_t scaned_matrix[MATRIX_ROWS];
    memset(scaned_matrix, 0, sizeof(scaned_matrix));

    for (int col = 0; col < MATRIX_COLS; col++) {
        if (col < sizeof(LEFT_COLS)) {
            // MCU側のキーマトリックススキャン
            // 略
        } else {
            // MCP23017側のMatrix

            // COL側、特定のCOLだけHIGHにして、他はLOWにする
            uint8_t      write_buf = RIGHT_COL_BITS[col - sizeof(LEFT_COLS)];
            i2c_status_t status    = i2c_write_register(MCP23017_I2C_ADDRESS << 1, MCP23017_GPIO_B, &write_buf, 1, MCP23017_I2C_TIMEOUT);
            dprintf("write I2C GPIOB i2c_status_t:%d value:0x%02X col:%d\n", status, write_buf, col);

            if (status != I2C_STATUS_SUCCESS) {
                return 0;
            }

            // ROW側、読み取り
            uint8_t read_buf;
            status = i2c_read_register(MCP23017_I2C_ADDRESS << 1, MCP23017_GPIO_A, &read_buf, 1, MCP23017_I2C_TIMEOUT);

            dprintf("read I2C GPIOA i2c_status_t:%d value:0x%02X col:%d\n", status, read_buf, col);


            if (status != I2C_STATUS_SUCCESS) {
                return 0;
            }

            for (int row = 0; row < sizeof(RIGHT_ROW_BITS); row++) {
                // ROWごとに1(ボタンが押された行)があるか確認
                if (read_buf & RIGHT_ROW_BITS[row]) {
                    // このループのCOLのbitを立てる。
                    scaned_matrix[row] |= 1 << col;
                }
            }
        }
    }

    // 更新があったかチェックして、QMK Firmwareにスキャン結果を戻す配列current_matrixを更新する
    bool updated = false;
    for (int row = 0; row < MATRIX_ROWS; row++) {
        if (current_matrix[row] != scaned_matrix[row]) {
            current_matrix[row] = scaned_matrix[row];
            updated             = true;
        }
    }

    return updated;
}

ポイントは以下です。

1 COLごとにループする。 2 COLごとのループで、対象のCOLのみHigh、それ以外はLowとなるように、レジスタGPIOBに書き込む。 3. 2.のあとにROWの各値を読み込むように、レジスタGPIOAを読み込む。読み込んでHighだった場合、スキャン結果の変数scaned_matrix[row]に書き込む。 4. 最後にscanned_matrixと引数current_matrixを比較して、引数current_matrixを更新する。また、更新された場合、戻り値にtrueを返す。

キーマトリクスの処理自体は非常に簡単なため、理屈がわかればそれほど実装量も多くありません。

このコードは以下で公開しています。

github.com

デアッグのポイント

上記コードにもdpritfがたくさん含まれるとおり、プリントデバッグを繰り返しました。

キーマトリックススキャンの度にログを出していると、毎秒100回近く動いているのもあり、結構ログの流量が多くなり、目で追えません。

そのため、私はデバッグログの出力を500msに1度しか行わないように工夫しました。

# ログの出力インターバル
#ifndef CUSTOM_MATRIX_DEBUG_INTERVAL
#    define CUSTOM_MATRIX_DEBUG_INTERVAL 500
#endif

static uint16_t d_timer = 0;

bool matrix_scan_custom(matrix_row_t current_matrix[]) {
    bool debug = false;
    if (timer_elapsed(d_timer) > CUSTOM_MATRIX_DEBUG_INTERVAL) {
        debug   = true;
        d_timer = timer_read();
    }

    if (debug) {
        dprintf("-- matrix_scan_custom start --\n");
    }

    // 略
}

ちゃんと動くか?

デバッグを繰り返し、ちゃんと動くようになりました!! 最近はSparrowDialなど、QMK Firmwareで作り込みをしていたため、その資産がそのまま使えるようになり、久しぶりに分割キーボードを楽しんでいます。

ただ、ファームウェア更新のためにRP2040側にリセットをかけたとき、I2C通信がそれ以降成功しない(ログ上もi2c_status=-1が返っている)ことが3回に1回くらい発生しました。MCP23017側が悪いのか、RP2040側が悪いのかまでは判別できていません。ただ、USBを再接続すると直ります。

RP2040から、I2CのVBUSを瞬断してMCP23017を再起動できるように、MOSFETを追加しても良かったなと思っています。

終わりに

ここのところはSparrow60C、SparrowDialと割れてないキーボードを使っていたのですが、やはり分割キーボードの良さとはまた別のものです。久しぶりに分割キーボードをQMK Firmwareで楽しめています。

Sparrow62v2ではMCP23017を利用していましたが、最近はSparrowTVでもIOエキスパンダとして、CH32V003F4P6にそのような実装をして利用しています。今後は、Sparrow62v3を作る際にはCH32V003F4P6を左右分割機能の要として活用したいと思っています。