コンテンツにスキップ
【自作キーボード】自作のConta基板で遊んでみよう!

【自作キーボード】自作のConta基板で遊んでみよう!

こんにちは。アルバイトの田中です。

2ヶ月ほど前の記事にて、「Conta規格基板の自作をしてみたでお会いしましょう」と締めて、早速第二弾の記事です!

今回はConta規格のサイズに目を付けて、機能を維持して取り外し可能なキーボードモジュールを製作してみました。最終的に、Picossciをキーボード化してみようと思います!

誰でも使えるツールで簡単に設計して発注できるので、ぜひ真似してみてください。

設計について

設計したのはキーボードのキースイッチとEEPROMが入っているだけのモジュールですが、電子工作の初心者でConta規格の自作erが現れることを期待して、設計の手順を簡単に書いていこうと思います。

まず、簡単に規格の寸法とピン配列のルールを紹介します。

基板のサイズは以下の画像の通りです。 基板サイズは788 milの正方形で、取り付け穴としてM2サイズの下穴が二つ開いています。 基板設計の際に基板カット用の外形寸法が必要となりますので、こちらからConta規格の外形図(dxf拡張子)をダウンロードしてご利用ください。

ピン配置は以下の通りです。I2CとSPI用ピンと、機能の指定がないIOピンが四つあります。今回は使いませんが、5Vの電源を使うことも可能です。

その他規格の詳細はこちらをご参照ください。

使用部品

部品は以上のものを使いました。Picossciへ搭載するので、同じものを4個製作します。大体一つ当たりの原価は300円くらいでしょうか。贅沢にEEPROMを搭載しているので少々コストがかかりますね。

使用ツール

  • KiCad 8.0(2025年3月現在で最新の9.0でも問題なし)
  • FreeCAD 1.0(最近大型アップデートが入り、使いやすくなりました。安定版の1.0になるまで何年かかったのでしょうか...)

ツールの詳しい使い方は、先人の知恵がインターネット上に掲載されているので、そちらをご参照ください。ここでは簡単に設計内容を説明します。KiCadでは以下のような回路設計しました。3 x ピンヘッダ1x6を規格通りに配線します。RP2350の内部プルアップ、プルダウン機能はラッチされるバグがあるそうなので、しっかりと回路でプルアップしてあげます。

それでは、実際の基板配線をしていきます。先ほど掲載したconta規格準拠のdxfファイルを読み込んで、基板外形のデータを読み込みます。

PCBのデザインは以下のようにしました。非公式の基板なのでConta 【Unofficial】KeyBoard Moduleと書いてみました。配線をしたあとは、安定動作のためにベタGNDも忘れないようにしましょう。

25AA010AT-I/OTはSPI通信で読み書きする不揮発メモリです。 今回のコンセプトとして、モジュールを差し込んで使える(機能が維持される)ことを考えていたため、押した際のキーの内容をメモリに保存していきます。

25AA010AT-I/OTのデータシートはこちら

設計が終わったら、KiCadでガーバーデータ(発注用のデータ形式)を生成して、基板発注先へデータをアップロードしてください。発注先は海外(特に中国)がおすすめです。国内業者はとても高品質な基板を製造してくれる一方で、趣味の工作用途ではコストが高いためおすすめしません。

ソフトについて

ソフトは自作キーボードの定番ファームウエアを改造しても良いですが、今回は簡単に開発することが目標なのでArduinoで開発していきます。

使用ツール

  • Arduino IDE 2.3.4
  • Keyboardライブラリ

Arduinoの「Keyboard」ライブラリを使った開発では、USB HID機能に対応していないため、PCからキーボードとして認識されず、オートリピート機能なども利用できません。そこで、Picossciに書き込むソフトウェア内で、これらの機能を補う仕組みを実装しています。

基板を発注してから気づいたのですが、PicossciのM1とM3、M2とM4はSPI通信用のCSピンが共通でした。そのため少々面倒ではありますが、書き込み時、読み込み時ともに抜き差ししてキーの機能を保存、出力する仕組みとなっています。(規格違反気味ながら、共通配線でないIO1またはIO4をEEPROMのCSピンにつないでおけばよかったと思いました...。)

ソフトはページ最下部に掲載しています。

完成

完成したキーボードモジュールは以下の通りです。

使い方

  1. キーモジュールに機能を書き込む必要があるので、書き込み用ソフトをPicossciに入れます。書き込みたいキーの機能はコードの以下の部分に入力します。対応する修飾キーはkeyboardライブラリの説明を参照ください。

    eepromWriteTwoBytes(SPI◯_SS, SPI◯_SCK, SPI◯_MOSI, SPI◯_MISO, 修飾キー(ctrlなど), 通常キー(英数字));
  2. Picossciからのシリアルデータに従ってキーを差し込み、データを書き込みます。

    以下の写真はM1、M2に差し込んだ状態。

    以下の写真はM3、M4に差し込んだ状態。


  3. キーへのデータ書き込みが終わったら、読み込みとキーボード機能用のソフトをPicossciに入れます。
  4. Picossciからのシリアルデータに従ってキーを差し込み、データをPicossciに読み込ませます。

  5. キーを押すとキーに書き込んだ機能が使えます。

    今回はM1にctrl+c、M2にctrl+v、M3にctrl、M4にDキーを書き込んだキーを差して読み込ませました。

大変面倒なことにCSピンが共通なため、キーの位置を入れ替える際はPicossciをリセットしてキーモジュールをすべて取り外し、順番に差し込む必要があります...。みなさまが設計する際は、共通配線でないIO1またはIO4をEEPROMのCSピンにしましょう。

この機能の利点は、ショートカットキーを割り当てて物理的に好きな場所にキーを割り当てられる点です。同じソフトを書いた別のボードでも、キーの使い回しができる点もメリットです。たとえば、キーに割り当てた機能に沿ったデコレーションをしていて、そのキーを差し替えるだけで任意の場所に機能が割り当てられたりします。

さいごに

最後まで読んでいただきありがとうございます。CSピンの仕様に気づかず、キーボードをリセットしてからキーの抜き差しをしなけらばならない点が非常に心残りです。次に改善するなら、EEPROMのCSピンをConta規格のIOピンに割り当てておきたいですね。

また、写真右下のようなベースシールドを用いて、さらにキーを拡張していきたいですね。


※価格は2025年3月時点のものです。

個人で簡単に基板を発注できると工作の幅が一気に広がります!!
この記事を読んで、Conta規格準拠ボードのためにConta規格基板の自作をしてみよう!と思った方がいらっしゃったら嬉しいです。

では、Conta規格記事の第3弾があればそちらでお会いしましょう。さようなら~。

 

今回使用したボード→Picossci 2 Conta™ Base(ESP-WROOM-02/RP2350A搭載)

Contaシリーズ特集ページはこちら

 

サンプルコード

書き込み用コード


#include <Keyboard.h>
#include <Wire.h>

#define SPI1_SS   13
#define SPI1_SCK  10
#define SPI1_MOSI 11
#define SPI1_MISO 12

#define SPI0_SS   5
#define SPI0_SCK  2
#define SPI0_MOSI 3
#define SPI0_MISO 4

#define EEPROM_OPCODE_READ   0x03 // 読み出し
#define EEPROM_OPCODE_WRITE  0x02 // 書き込み
#define EEPROM_OPCODE_WREN   0x06 // 書き込みイネーブル
#define EEPROM_OPCODE_RDSR   0x05 // ステータスレジスタ読み出し
#define SR_WIP_BIT           0x01 // Write In Progress フラグ

void softSPI_shiftOut(uint8_t dataPin, uint8_t clockPin, uint8_t bitOrder, uint8_t val) {
  for (int i = 0; i < 8; i++) {
    if (bitOrder == MSBFIRST) {
      digitalWrite(dataPin, (val & 0x80) ? HIGH : LOW);
      val <<= 1;
    } else {
      digitalWrite(dataPin, (val & 0x01) ? HIGH : LOW);
      val >>= 1;
    }
    digitalWrite(clockPin, HIGH);
    digitalWrite(clockPin, LOW);
  }
}

uint8_t softSPI_shiftIn(uint8_t dataPin, uint8_t clockPin, uint8_t bitOrder) {
  uint8_t value = 0;
  for (int i = 0; i < 8; i++) {
    digitalWrite(clockPin, HIGH);
    if (bitOrder == MSBFIRST) {
      value <<= 1;
      if (digitalRead(dataPin)) value |= 0x01;
    } else {
      value >>= 1;
      if (digitalRead(dataPin)) value |= 0x80;
    }
    digitalWrite(clockPin, LOW);
  }
  return value;
}

void eepromSendWREN(uint8_t csPin, uint8_t sckPin, uint8_t mosiPin) {
  // CS Low → オペコード送信 → CS High
  digitalWrite(csPin, LOW);
  softSPI_shiftOut(mosiPin, sckPin, MSBFIRST, EEPROM_OPCODE_WREN);
  digitalWrite(csPin, HIGH);
}

uint8_t eepromReadStatus(uint8_t csPin, uint8_t sckPin, uint8_t mosiPin, uint8_t misoPin) {
  uint8_t status;
  digitalWrite(csPin, LOW);
  softSPI_shiftOut(mosiPin, sckPin, MSBFIRST, EEPROM_OPCODE_RDSR);
  status = softSPI_shiftIn(misoPin, sckPin, MSBFIRST);
  digitalWrite(csPin, HIGH);
  return status;
}

void eepromWriteByte(uint8_t csPin, uint8_t sckPin, uint8_t mosiPin, uint8_t misoPin,
                     uint8_t address, uint8_t dataByte)
{
  // 1) WREN
  eepromSendWREN(csPin, sckPin, mosiPin);

  // 2) WRITE コマンド
  digitalWrite(csPin, LOW);
  softSPI_shiftOut(mosiPin, sckPin, MSBFIRST, EEPROM_OPCODE_WRITE);
  softSPI_shiftOut(mosiPin, sckPin, MSBFIRST, address);
  softSPI_shiftOut(mosiPin, sckPin, MSBFIRST, dataByte);
  digitalWrite(csPin, HIGH);

  // 3) 書き込み完了待ち (WIP=0になるまで)
  while (true) {
    uint8_t sr = eepromReadStatus(csPin, sckPin, mosiPin, misoPin);
    if ((sr & SR_WIP_BIT) == 0) {
      break;
    }
    delay(1);
  }
}

uint8_t eepromReadByte(uint8_t csPin, uint8_t sckPin, uint8_t mosiPin, uint8_t misoPin,
                       uint8_t address)
{
  uint8_t data;
  digitalWrite(csPin, LOW);
  softSPI_shiftOut(mosiPin, sckPin, MSBFIRST, EEPROM_OPCODE_READ);
  softSPI_shiftOut(mosiPin, sckPin, MSBFIRST, address);
  data = softSPI_shiftIn(misoPin, sckPin, MSBFIRST);
  digitalWrite(csPin, HIGH);
  return data;
}

void eepromWriteTwoBytes(uint8_t csPin, uint8_t sckPin, uint8_t mosiPin, uint8_t misoPin,
                         uint8_t modKey, uint8_t normalKey)
{
  eepromWriteByte(csPin, sckPin, mosiPin, misoPin, 0x00, modKey);
  eepromWriteByte(csPin, sckPin, mosiPin, misoPin, 0x01, normalKey);
}

void setup() {
  Serial.begin(115200);
  while (!Serial) ;
  Keyboard.begin();

  pinMode(SPI1_SS, OUTPUT); digitalWrite(SPI1_SS, HIGH);
  pinMode(SPI1_SCK, OUTPUT); digitalWrite(SPI1_SCK, LOW);
  pinMode(SPI1_MOSI, OUTPUT); digitalWrite(SPI1_MOSI, LOW);
  pinMode(SPI1_MISO, INPUT);

  pinMode(SPI0_SS, OUTPUT); digitalWrite(SPI0_SS, HIGH);
  pinMode(SPI0_SCK, OUTPUT); digitalWrite(SPI0_SCK, LOW);
  pinMode(SPI0_MOSI, OUTPUT); digitalWrite(SPI0_MOSI, LOW);
  pinMode(SPI0_MISO, INPUT);

  Serial.println("[STEP1] M1, M2 を挿した状態で電源ON");
  Serial.println("    これから M1, M2 に 2バイト(修飾キー + 通常キー)を書き込みます。");
  Serial.println("    Enter を押してください。");
}
void loop() {
  static int step = 0;
  if (Serial.available() > 0) {
    // ユーザーが何かキーを押したら次のステップへ
    while (Serial.available()) {
      Serial.read(); // バッファを消費
    }
    step++;
    switch (step) {
      case 1:
        // M1
        eepromWriteTwoBytes(SPI1_SS, SPI1_SCK, SPI1_MOSI, SPI1_MISO,
                            KEY_RIGHT_CTRL, 'c');

        // M2
        eepromWriteTwoBytes(SPI0_SS, SPI0_SCK, SPI0_MOSI, SPI0_MISO,
                            KEY_RIGHT_CTRL, 'v');

        Serial.println();
        Serial.println("[STEP2] 書き込み完了。M1, M2 を取り外して M3, M4 を挿してください。");
        Serial.println("    Enter で次へ。");
        break;

      case 2:
        // M3
        eepromWriteTwoBytes(SPI1_SS, SPI1_SCK, SPI1_MOSI, SPI1_MISO,
                            KEY_LEFT_CTRL, 0x00);

        // M4
        eepromWriteTwoBytes(SPI0_SS, SPI0_SCK, SPI0_MOSI, SPI0_MISO,
                            0x00, 'D');

        Serial.println();
        Serial.println("すべての書き込みが完了しました。");
        break;

      default:
        Serial.println("これ以上のステップはありません。");
        break;
    }
  }
}

読み込みとキーボード機能のコード


#include <Keyboard.h>
#include <SPI.h>

#define M1_IO4    17
#define M2_IO4    19
#define M3_IO4    21
#define M4_IO4    23

// SPI0
#define SPI0_SS   5 
#define SPI0_SCK  2
#define SPI0_MOSI 3
#define SPI0_MISO 4

// SPI1
#define SPI1_SS   13
#define SPI1_SCK  10
#define SPI1_MOSI 11
#define SPI1_MISO 12

#define EEPROM_READ_OPCODE  0x03

const unsigned long INITIAL_REPEAT_DELAY = 500; // ms
const unsigned long REPEAT_INTERVAL      = 50;  // ms

const unsigned long DEBOUNCE_DELAY = 20;

struct KeyDefinition {
  uint8_t modKey;   
  uint8_t normalKey;
};

// 4つのモジュールに対応するキー定義
KeyDefinition keyM1;
KeyDefinition keyM2;
KeyDefinition keyM3;
KeyDefinition keyM4;

struct KeyState {
  bool wasPressed;
  unsigned long pressStart;
  unsigned long lastRepeat;
  bool holdActive;  
};

// 4つのモジュール分の状態
KeyState ksM1 = {false, 0, 0, false};
KeyState ksM2 = {false, 0, 0, false};
KeyState ksM3 = {false, 0, 0, false};
KeyState ksM4 = {false, 0, 0, false};

struct DebounceState {
  bool stableState;       // 現在確定している論理状態(LOW=true=押下, HIGH=false=未押下)かどうか
  bool lastRawReading;    // 直近の生の入力読み取り値
  unsigned long lastChangeTime;  // 状態が変わったときの時刻
};

// 4つのモジュール分のデバウンス状態
DebounceState dbM1 = {false, false, 0};
DebounceState dbM2 = {false, false, 0};
DebounceState dbM3 = {false, false, 0};
DebounceState dbM4 = {false, false, 0};

uint8_t readEEPROM_25AA010A(uint8_t spiNumber, uint8_t csPin, uint8_t address)
{
  digitalWrite(csPin, LOW);
  if (spiNumber == 0) {
    shiftOut(SPI0_MOSI, SPI0_SCK, MSBFIRST, EEPROM_READ_OPCODE);
    shiftOut(SPI0_MOSI, SPI0_SCK, MSBFIRST, address);
    pinMode(SPI0_MISO, INPUT);
    uint8_t dataIn = shiftIn(SPI0_MISO, SPI0_SCK, MSBFIRST);
    digitalWrite(csPin, HIGH);
    return dataIn;
  } else {
    shiftOut(SPI1_MOSI, SPI1_SCK, MSBFIRST, EEPROM_READ_OPCODE);
    shiftOut(SPI1_MOSI, SPI1_SCK, MSBFIRST, address);
    pinMode(SPI1_MISO, INPUT);
    uint8_t dataIn = shiftIn(SPI1_MISO, SPI1_SCK, MSBFIRST);
    digitalWrite(csPin, HIGH);
    return dataIn;
  }
}

bool isHoldKey(uint8_t keyCode)
{
  switch (keyCode) {
    case KEY_LEFT_CTRL:
    case KEY_LEFT_SHIFT:
    case KEY_LEFT_ALT:
    case KEY_LEFT_GUI:
    case KEY_RIGHT_CTRL:
    case KEY_RIGHT_SHIFT:
    case KEY_RIGHT_ALT:
    case KEY_RIGHT_GUI:
      return true;
  }
  return false;
}

void beginHoldKey(uint8_t keyCode, KeyState &ks)
{
  Keyboard.press(keyCode);
  ks.holdActive = true;
}

void endHoldKey(uint8_t keyCode, KeyState &ks)
{
  if (ks.holdActive) {
    Keyboard.release(keyCode);
    ks.holdActive = false;
  }
}

void sendNormalKey(uint8_t keyCode)
{
  Keyboard.press(keyCode);
  delay(10);
  Keyboard.release(keyCode);
}

void sendCombinedKey(uint8_t modKey, uint8_t normalKey)
{
  Keyboard.press(modKey);
  Keyboard.press(normalKey);
  delay(10);
  Keyboard.releaseAll();
}

void handleKeyPressed(const KeyDefinition &kd, KeyState &ks)
{
  if (kd.modKey != 0) {
    if (isHoldKey(kd.modKey)) {
      if (kd.normalKey == 0) {
        // 修飾キーのみ → hold
        beginHoldKey(kd.modKey, ks);
      } else {
        // 修飾キー + 通常キー → ワンショット同時押し
        sendCombinedKey(kd.modKey, kd.normalKey);
      }
    } else {
      // modKey が holdKey じゃない(例: CapsLockなど)
      if (kd.normalKey == 0) {
        sendNormalKey(kd.modKey);
      } else {
        sendCombinedKey(kd.modKey, kd.normalKey);
      }
    }
  }
  else {
    // 修飾キーなし → 通常キーのみ
    if (kd.normalKey != 0) {
      sendNormalKey(kd.normalKey);
    }
  }
}

void handleKeyPress(bool nowPressed, const KeyDefinition &kd, KeyState &ks)
{
  unsigned long now = millis();

  // 押され始めた
  if (nowPressed && !ks.wasPressed) {
    handleKeyPressed(kd, ks);
    ks.pressStart = now;
    ks.lastRepeat = 0;
  }
  // 継続押下
  else if (nowPressed && ks.wasPressed) {
    bool hasHoldKey = (kd.modKey != 0 && isHoldKey(kd.modKey));
    if (!hasHoldKey && kd.normalKey != 0) {
      // オートリピート対象
      unsigned long heldTime = now - ks.pressStart;
      if (heldTime > INITIAL_REPEAT_DELAY) {
        if ((now - ks.lastRepeat) > REPEAT_INTERVAL) {
          handleKeyPressed(kd, ks); 
          ks.lastRepeat = now;
        }
      }
    }
    // holdKeyあり → ずっと押しっぱなし維持
  }
  // 離された
  else if (!nowPressed && ks.wasPressed) {
    // holdKey中なら release
    if (kd.modKey != 0 && isHoldKey(kd.modKey)) {
      endHoldKey(kd.modKey, ks);
    }
  }

  ks.wasPressed = nowPressed;
}

bool updateDebounce(DebounceState &db, bool rawReading)
{
  // 今回のrawが前回と異なる?
  if (rawReading != db.lastRawReading) {
    // 状態が変わった → 最終変更時刻を更新
    db.lastChangeTime = millis();
    db.lastRawReading = rawReading;
  }
  else {
    // 変わっていない → 一定時間経過したら stableState を更新
    if ((millis() - db.lastChangeTime) > DEBOUNCE_DELAY) {
      // stable状態をrawに合わせる
      db.stableState = db.lastRawReading;
    }
  }

  return db.stableState;
}

void setup() {
  Serial.begin(115200);
  while (!Serial) {}

  Keyboard.begin();

  // CSピン設定
  pinMode(SPI0_SS, OUTPUT); digitalWrite(SPI0_SS, HIGH);
  pinMode(SPI1_SS, OUTPUT); digitalWrite(SPI1_SS, HIGH);

  // スイッチ入力ピン
  pinMode(M1_IO4, INPUT_PULLUP);
  pinMode(M2_IO4, INPUT_PULLUP);
  pinMode(M3_IO4, INPUT_PULLUP);
  pinMode(M4_IO4, INPUT_PULLUP);

  // ソフトSPI用ピン
  pinMode(SPI0_SCK,  OUTPUT);
  pinMode(SPI0_MOSI, OUTPUT);
  pinMode(SPI0_MISO, INPUT);
  pinMode(SPI1_SCK,  OUTPUT);
  pinMode(SPI1_MOSI, OUTPUT);
  pinMode(SPI1_MISO, INPUT);

  Serial.println("[STEP1] M1, M2 を挿してEnterを押してください。");
  while (!Serial.available()) {}
  while (Serial.available()) { Serial.read(); }

  keyM1.modKey    = readEEPROM_25AA010A(1, SPI1_SS, 0x00);
  keyM1.normalKey = readEEPROM_25AA010A(1, SPI1_SS, 0x01);

  keyM2.modKey    = readEEPROM_25AA010A(0, SPI0_SS, 0x00);
  keyM2.normalKey = readEEPROM_25AA010A(0, SPI0_SS, 0x01);

  Serial.print("M1 => modKey=0x"); Serial.print(keyM1.modKey, HEX);
  Serial.print(", normalKey=0x");  Serial.println(keyM1.normalKey, HEX);
  Serial.print("M2 => modKey=0x"); Serial.print(keyM2.modKey, HEX);
  Serial.print(", normalKey=0x");  Serial.println(keyM2.normalKey, HEX);

  Serial.println();
  Serial.println("[STEP2] M1, M2 を外し、M3, M4 を挿してEnterを押してください。");
  while (!Serial.available()) {}
  while (Serial.available()) { Serial.read(); }

  keyM3.modKey    = readEEPROM_25AA010A(1, SPI1_SS, 0x00);
  keyM3.normalKey = readEEPROM_25AA010A(1, SPI1_SS, 0x01);

  keyM4.modKey    = readEEPROM_25AA010A(0, SPI0_SS, 0x00);
  keyM4.normalKey = readEEPROM_25AA010A(0, SPI0_SS, 0x01);

  Serial.print("M3 => modKey=0x"); Serial.print(keyM3.modKey, HEX);
  Serial.print(", normalKey=0x");  Serial.println(keyM3.normalKey, HEX);
  Serial.print("M4 => modKey=0x"); Serial.print(keyM4.modKey, HEX);
  Serial.print(", normalKey=0x");  Serial.println(keyM4.normalKey, HEX);

  Serial.println();
  Serial.println("読み込み完了");
}

void loop() {
  // 1) 各入力ピンの生の状態(LOW=押下, HIGH=未押下)を読む
  bool rawM1 = (digitalRead(M1_IO4) == LOW);
  bool rawM2 = (digitalRead(M2_IO4) == LOW);
  bool rawM3 = (digitalRead(M3_IO4) == LOW);
  bool rawM4 = (digitalRead(M4_IO4) == LOW);

  // 2) デバウンス更新 → 確定した押下状態
  bool pressedM1 = updateDebounce(dbM1, rawM1);
  bool pressedM2 = updateDebounce(dbM2, rawM2);
  bool pressedM3 = updateDebounce(dbM3, rawM3);
  bool pressedM4 = updateDebounce(dbM4, rawM4);

  // 3) handleKeyPress() で処理
  handleKeyPress(pressedM1, keyM1, ksM1);
  handleKeyPress(pressedM2, keyM2, ksM2);
  handleKeyPress(pressedM3, keyM3, ksM3);
  handleKeyPress(pressedM4, keyM4, ksM4);

  // 適当にループを少し待つ(1〜5ms程度)
  delay(5);
}

 

 

前の記事 Fill In the Gaps With These New Products
次の記事 【MyActuator】RMD-X4を動かしてみる