📚

Dreamcast の USB キーボードを作る

2022/05/15に公開

キーボード用コントローラを作る

前回がテンキーの改造でした。
次は、より一般的なキーボードを改造して QMK Firmware 化します。

https://zenn.dev/tryjsky/articles/692eb8cb489977

Dreamcast HKT-4000

コンパクトな 92 キーの TKL キーボードです。
0.75u を多用して、キーの省略なく Fn キーを使わず 14.25u の幅を実現しています。
Dreamcast の周辺機器バス用に作られているため、PC で使うことは考えられていません。

分解

裏面のネジ 3 本を外します。隠しネジなどは使われていません。
ネジ周辺は強固にプラスチックの爪で止まっていますが、横の爪は筐体を押し込むと外れます。
手前に軽く引きながら手前の爪をはがしていきます。

基板には "SEGA MAPLE BUS" とロゴの入ったチップがあり、制御と通信を司っています。
それ以外に、BCD Decoder の LS145 が 2 枚使われており、制御ピン数の削減に使われています。
余談ですが、同じく Dreamcast 用の HKT-7600 も同様の構成だそうなのでシリーズ共通かもしれません。



回路図

キーマトリクス周辺の回路です。
C14 〜 C18 はシルク印刷はあってもキャパシタが実装されていません。

3 ピンのアドレスと 2 ピンのチップセレクトで LS145 を制御することで、CN1 の任意のピンが GND に落ちます。
なお (CS0) == ~(CS1) の制約があります。
CN3 側がプルアップされているため、キーを押すと対応する D0 〜 D7 の出力が LOW になります。

キーマトリクスだけ見れば 16 + 8 = 24 ピンが必要ですが、LS145 のおかげで 5 + 8 = 13 ピンで制御できる回路になっています。
Pro micro の I/O でも数が足ります。

製作

準備するもの

  • Pro micro (5v 16MHz) ×1
  • USB ケーブル
    PC の接続に使います。
  • ワイヤー線
    今回もポリウレタン銅線 (0.2mm) を使いましたが、普通のラッピングワイヤーでも作れそうです。
  • はんだ線
  • 工具類
    プラスドライバー、はんだごて、ピンセット、カッター、テスターなど

結線

基板には 5v を給電します。外部と通信する CN2 は抜いておきます。
C14 〜 C18 は空きランドになっているので、チップに近い側に結線してください。

基板 Pro micro
CN3 - 1 2 (PD1)
CN3 - 2 3 (PD0)
CN3 - 3 4 (PD4)
CN3 - 4 5 (PC6)
CN3 - 5 6 (PD7)
CN3 - 6 8 (PB4)
CN3 - 7 RXI (PD2)
CN3 - 8 TXO (PD3)
C14 9 (PB5)
C15 15 (PB1)
C16 14 (PB3)
C17 16 (PB2)
C18 10 (PB6)

マトリクス解析

ここからソフトウェアを作ります。

最適化されたキーボードではキーマトリクスと見た目の配列を一致させないことがあります。
Arduino でファームウェアのプロトタイプを作り、キーマトリクスを解析します。

スケッチを Pro micro に書き込んでキーを押し続けると row, col の組み合わせが表示されます。
1 キーごとに key_matrix を更新してコンパイルします。

プログラムの内容は、I/O を読み取って表示するだけのものです。
ノーウェイトでアドレス線とデータ線を読み取っています。
信号線を digitalRead() で 1bit ずつ読み取っていると途中で値が変化してしまうかもしれないため、PORTB, PORTD のレジスタ単位で読み取っています。

const char * const key_matrix[16][8] = {
    { "KC_F7",   "KC_F9",   "KC_F5",   0,         0,         0,         "KC_F3",   "KC_GRV"  }, /* 10000 =>  1 */
    // Add identified keys
};
int oldrowcol = 0;

void setup() {
  Serial.begin(9600);

  // row
  pinMode(9, INPUT);
  pinMode(15, INPUT);
  pinMode(14, INPUT);
  pinMode(16, INPUT);
  pinMode(10, INPUT);

  // column
  pinMode(1, INPUT);
  pinMode(0, INPUT);
  pinMode(2, INPUT);
  pinMode(3, INPUT);
  pinMode(4, INPUT);
  pinMode(5, INPUT);
  pinMode(6, INPUT);
  pinMode(8, INPUT);
}

void loop() {
  delay(20);

  noInterrupts();
  uint8_t portb = PINB;
  uint8_t portd = PIND;
  interrupts();
  int row = portb & B01101110;
  int col = -1;
  int rowcol;

  if (row == 0 || (row & B01000100) == B01000100 ) {
    return;
  }

  row = //(row >> 2 & B10000) |
      (row << 1 & B1000) |
      (row >> 1 & B100) |
      (row & B10) |
      (row >> 5 & B1);
  
  // D1
  if ((portd & (1 << 3)) == LOW) {
    col = 7;
  }
  // D0
  if ((portd & (1 << 2)) == LOW) {
    col = 6;
  }
  // D2
  if ((portd & (1 << 1)) == LOW) {
    col = 0;
  }
  // D3
  if ((portd & (1 << 0)) == LOW) {
    col = 1;
  }
  // D4
  if ((portd & (1 << 4)) == LOW) {
    col = 2;
  }
  // D5
  if (digitalRead(5) == LOW) {
    col = 3;
  }
  // D6
  if (digitalRead(6) == LOW) {
    col = 4;
  }
  // D8
  if ((portb & (1 << 4)) == LOW) {
    col = 5;
  }

  rowcol = row << 4 | col;
  if (col >= 0 && rowcol != oldrowcol) {
    if (key_matrix[row][col] != 0) {
      Serial.println(key_matrix[row][col]);
    } else {
      Serial.println(row, BIN);
      Serial.println(col);
    }
    oldrowcol = rowcol;
  }
}

アドレス線駆動

いよいよ row を自力でドライブします。
制御チップからの配線を切り離します。

改修したスケッチを書き込み、キーを押して動作が一致するか確認します。
row 側の pinMode() を OUTPUT に初期化して、読み取り前に PORTB が更新されるように変更しました。
データ線の読み取りがレジスタのまま試すと動かなくなったので digitalRead() に戻すと動作するようになりました。
おそらくディレイが足りなかったことが理由でしょう。

const char * const key_matrix[16][8] = {
  { "KC_F7",   "KC_F9",   "KC_F5",   0,         0,         0,         "KC_F3",   "KC_GRV"  }, /* 10000 =>  1 */
  { "KC_INT5", "KC_1",    0,         0,         0,         0,         "KC_F2",   0         }, /* 10001 =>  2 */
  { "KC_SCRL", "KC_LCTL", "KC_CAPS", 0,         "KC_APP",  "KC_RCTL", "KC_LGUI", "KC_TAB"  }, /* 10010 =>  3 */
  { 0,         "KC_LSFT", 0,         0,         0,         "KC_RSFT", "KC_V",    0         }, /* 10011 =>  4 */
  { 0,         "KC_RGUI", 0,         "KC_LALT", "KC_RALT", "KC_INT1", "KC_LEFT", "KC_B"    }, /* 10100 =>  5 */
  { 0,         0,         "KC_2",    "KC_Q",    "KC_A",    "KC_S",    "KC_Z",    "KC_X"    }, /* 10101 =>  6 */
  { 0,         0,         "KC_3",    "KC_4",    "KC_W",    "KC_E",    "KC_D",    "KC_C"    }, /* 10110 =>  7 */
  { 0,         0,         0,         0,         0,         "KC_ENT",  "KC_RBRC", "KC_BSLS" }, /* 10111 =>  8 */
  { "KC_F11",  "KC_BSPC", "KC_EQL",  "KC_INT3", "KC_P",    "KC_LBRC", "KC_QUOT", "KC_SLSH" }, /* 01000 =>  9 */
  { 0,         "KC_SCLN", 0,         "KC_MINS", "KC_K",    "KC_O",    0,         "KC_L"    }, /* 01001 => 10 */
  { "KC_DOT",  "KC_COMM", "KC_9",    "KC_0",    "KC_U",    "KC_I",    "KC_J",    "KC_M"    }, /* 01010 => 11 */
  { 0,         "KC_7",    "KC_8",    "KC_T",    "KC_Y",    "KC_G",    "KC_H",    "KC_N"    }, /* 01011 => 12 */
  { "KC_F8",   "KC_F12",  "KC_5",    "KC_6",    "KC_R",    "KC_F",    "KC_F1",   "KC_ESC"  }, /* 01100 => 13 */
  { "KC_PGUP", "KC_PGDN", "KC_F6",   "KC_F10",  0,         "KC_HOME", "KC_F4",   "KC_DOWN" }, /* 01101 => 14 */
  { "KC_UP",   "KC_SPC",  0,         "KC_INT4", 0,         0,         "KC_INT2", "KC_END"  }, /* 01110 => 15 */
  { "KC_DEL",  "KC_PSCR", 0,         0,         0,         "KC_PAUS", "KC_INS",  "KC_RGHT" }, /* 01111 => 16 */
  };
int oldrowcol = 0;

void setup() {
  Serial.begin(9600);

  // row
  pinMode(9, OUTPUT);
  pinMode(15, OUTPUT);
  pinMode(14, OUTPUT);
  pinMode(16, OUTPUT);
  pinMode(10, OUTPUT);

  // column
  pinMode(1, INPUT);
  pinMode(0, INPUT);
  pinMode(2, INPUT);
  pinMode(3, INPUT);
  pinMode(4, INPUT);
  pinMode(5, INPUT);
  pinMode(6, INPUT);
  pinMode(8, INPUT);
}

void loop() {
  delay(20);

  for (int row = 0; row < 16; row++) {
    /* drive decoder */
    uint8_t rowbit = (((row > B0111) ? 0 : 1) << 6) |
      (((row > B0111) ? 1 : 0) << 2) |
      ((row & B0100) << 1) |
      (row & B0010) |
      ((row & B0001) << 5);
    PORTB = (PORTB & B10010001) | rowbit;

    int col = -1;
    int rowcol;
  
    // D1
    if (digitalRead(1) == LOW) {
      col = 7;
    }
    // D0
    if (digitalRead(0) == LOW) {
      col = 6;
    }
    // D2
    if (digitalRead(2) == LOW) {
      col = 0;
    }
    // D3
    if (digitalRead(3) == LOW) {
      col = 1;
    }
    // D4
    if (digitalRead(4) == LOW) {
      col = 2;
    }
    // D5
    if (digitalRead(5) == LOW) {
      col = 3;
    }
    // D6
    if (digitalRead(6) == LOW) {
      col = 4;
    }
    // D8
    if (digitalRead(8) == LOW) {
      col = 5;
    }
  
    rowcol = row << 4 | col;
    if (col >= 0 && rowcol != oldrowcol) {
      if (key_matrix[row][col] != 0) {
        Serial.println(key_matrix[row][col]);
      } else {
        Serial.println(row, BIN);
        Serial.println(col);
      }
      oldrowcol = rowcol;
    }
  }

  // clear decoder bit
  PORTB |= B01101110;
}

QMK Firmware

QMK Firmware は マトリクス・スキャンをユーザルーチンに置き換える Custom Matrix の機能が選べます。[1]
簡易版の lite は、rules.mkCUSTOM_MATRIX = lite の追加と
新規ファイル matrix.c に初期化関数 matrix_init_custom(), マトリクス・スキャン関数 matrix_scan_custom() の追加で動作します。
先ほどプロトタイプで作成したロジックをほぼそのまま使って省力化できます。

matrix.c
void matrix_init_custom(void) {
    // Init row pins
    setPinOutput(B5);
    setPinOutput(B1);
    setPinOutput(B3);
    setPinOutput(B2);
    setPinOutput(B6);

    // Init col pins
    setPinInput(D1);
    setPinInput(D0);
    setPinInput(D4);
    setPinInput(C6);
    setPinInput(D7);
    setPinInput(B4);
    setPinInput(D2);
    setPinInput(D3);
}

bool matrix_scan_custom(matrix_row_t current_matrix[]) {
    bool matrix_has_changed = false;
    matrix_row_t curr_matrix[MATRIX_ROWS] = {0};

    for (uint8_t row = 0; row < MATRIX_ROWS; row++) {
        matrix_row_t row_value = 0;

        /* drive row decoder */
        uint8_t rowbit = (((row > 0b0111) ? 0 : 1) << 6) |
            (((row > 0b0111) ? 1 : 0) << 2) |
            ((row & 0b0100) << 1) |
            (row & 0b0010) |
            ((row & 0b0001) << 5);
        PORTB = (PORTB & 0b10010001) | rowbit;
        matrix_output_select_delay();
        matrix_output_select_delay();
        matrix_output_select_delay();

        row_value |= readPin(D1) ? 0 : MATRIX_ROW_SHIFTER << 0;
        row_value |= readPin(D0) ? 0 : MATRIX_ROW_SHIFTER << 1;
        row_value |= readPin(D4) ? 0 : MATRIX_ROW_SHIFTER << 2;
        row_value |= readPin(C6) ? 0 : MATRIX_ROW_SHIFTER << 3;
        row_value |= readPin(D7) ? 0 : MATRIX_ROW_SHIFTER << 4;
        row_value |= readPin(B4) ? 0 : MATRIX_ROW_SHIFTER << 5;
        row_value |= readPin(D2) ? 0 : MATRIX_ROW_SHIFTER << 6;
        row_value |= readPin(D3) ? 0 : MATRIX_ROW_SHIFTER << 7;

        curr_matrix[row] = row_value;
    }

    matrix_has_changed = memcmp(current_matrix, curr_matrix, sizeof(curr_matrix)) != 0;
    if (matrix_has_changed) {
        memcpy(current_matrix, curr_matrix, sizeof(curr_matrix));
    }

    return matrix_has_changed;
}

MCU のピンには NO_PIN を指定します。

info.json
    "matrix_pins": {
        "cols": ["NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN"],
        "rows": ["NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN", "NO_PIN"]
    },

作成したキーボードは個人リポジトリで公開しています。
クローンしてブランチ dreamcast を参照してください。

$ git clone -b dreamcast https://github.com/tryjsky/qmk_firmware.git
$ qmk flash -kb handwired/dreamcast -km default

ディレイについて

キーマトリクスの基本原理は、row を選択したときにキースイッチが押されていることによる col の変化を検出することですが
MCU の I/O ピンに電圧変化が反映されるまでは、わずかに遅れがあります。

動作確認をしていると、col = 0, 1 のキーだけ二つのキーが押されたと検出されるパターンが見つかりました。
たとえば、「1」を押したはずが次の row の「Ctrl」も押されたことになっているなどです。

原因を調べていくと、row を変化させて col を読み取るまでのディレイが足りておらず、前回の row の影響を読み取っている状況が見えてきました。
col を 0, 1, ..., 7 と順に読み取っていたため、途中からは正しく読み取れていたようです。
ディレイとして入れていた matrix_output_select_delay() が短すぎたため、呼び出し回数を三回に増やすことで解消しました。

さいごに

ケーブルを USB に置き換えれば、ひとまず実用的な USB キーボードの出来上がりです。

ソフトウェアの進化により、時間も資金もほとんどかけずに自作できるようになりました。
過去の思い入れのあるキーボードを、余暇で復活させるのも面白いのではないでしょうか。

脚注
  1. Custom Matrix ↩︎

Discussion