ラズパイピコでトレイン シミュレーター用コントローラーを作成する (その 2)

マイコン

概要

Raspberry Pi Pico の USB 機能を利用し、JR 東日本トレインシミュレーター用の簡易コントローラーを作成します。前回は 3 ボタンのシンプルなものでしたが、今回は以下のように拡張したいと思います。

  • 加減速の操作をダイヤル式にする。
  • カーソル キー、Enter キー、Esc キーを実装する。

また、前回は加減速、ニュートラルはショートカット キーを送信していましたが、今回はマウス メッセージを送信するようにします。

作成する機能

以下の操作を行えるようにします。

  • メニュー操作 (出発駅選択、ポーズ、再開、終了など)
  • 加速強め/ブレーキ弱め (P)
  • 加速弱め/ブレーキ強め (B)
  • ニュートラル (N)
  • レバーサー操作

今回も、作成はブレッドボードを使用します。

ハードウェア

使用する入力デバイス

前回はタクト スイッチのみを使用しましたが、今回は機能により以下のデバイスを使用します。

機能デバイス
加速/減速ダイヤルロータリー エンコーダ
ニュートラルタクト スイッチ
カーソル キージョイ スティック
Enter キージョイ スティック (プッシュ)
Esc キータクト スイッチ
使用デバイス
入力デバイスの写真
入力デバイスの写真

使用パーツ

使用するパーツを以下の表にまとめます。これらは、秋月電子通商などで購入できます。表中のパーツ名は、秋月電子通商の商品ページにリンクしています。

パーツ名型番数量備考
Raspberry Pi Pico1
ブレッドボードBB-1021
タクト スイッチTS-06062基板取付用タイプ
ジョイ スティック (5 ポジション スイッチ) DIP 化キットAE-SKRHAAE010-BO1
ロータリー エンコーダ (24 クリック タイプ)EC12E24208011
ロータリー エンコーダ DIP 化基板 RECNV-1AE-RECNV-11
大型つまみ (ノブ) 33mmABS-648-331
細ピン ヘッダーPHA-1x20SG2シングル 20 ピン
ジャンパ ワイヤー165-012-000(EIC-J-L)適量単線タイプ (型番は一例)
ジャンパ ワイヤー0116-719-05-015適量より線タイプ (型番は一例)
USB ケーブル1A オス – マイクロ B オス
パーツ一覧

回路図

回路図は以下の通りです。

回路図
回路図

部品配置

以下の写真のように部品を配置し、配線します。配線が終わったら、ロータリー エンコーダのシャフトに、つまみを取り付けます。

部品配置
部品配置

ファームウェア

TinyUSB のサンプルを流用

ファームウェアは、前回と同様に TinyUSB のサンプル コード dev_hid_composite を改造して作成します。Raspberry Pi Pico を HID デバイスとして動作させ、Windows からはキーボード/マウス デバイスとして認識させます。

サンプルの主な改造ポイントを以降で説明します。

ボタン入力ピン割り当て

デバイスからの入力を受け付けるため、GPIO ピンを次のように割り当てます。ジョイ スティックに関しては、実装する向きの関係 (反時計回りに 90°回転している) で、ジョイ スティックの機能 (UP/DOWN/RIGHT/LEFT) とアサインされている機能が一致していません。GP25 は基板上の LED を点滅させるために使用します。GP5 が未使用になっていますが、特に深い意味はありません (当初ボタンを割り当てるつもりだったが、結局使用しなかった)。

GPIO機能
GP2ロータリー エンコーダ A 相入力
GP3ロータリー エンコーダ B 相入力
GP4ニュートラル
GP6[Enter] キー (ジョイ スティック: SW)
GP7[Esc] キー
GP8[↑] キー (ジョイ スティック: RT)
GP9[↓] キー (ジョイ スティック: LT)
GP10[→] キー (ジョイ スティック: DW)
GP11[←] キー (ジョイ スティック: UP)
GPIO のアサイン
C
// GPIO ピン/ボタン ID の定義
enum
{
    LED_PIN = 25,       // LED
    ENCODER_A = 2,      // ロータリー エンコーダ A 相
    ENCODER_B = 3,      // ロータリー エンコーダ B 相
    BUTTON_ACCEL = 101, // 加速強め/ブレーキ弱め
    BUTTON_BRAKE = 102, // 加速弱め/ブレーキ強め
    BUTTON_NEUTRAL = 4, // ニュートラル
    BUTTON_ENTER = 6,   // [Enter] キー
    BUTTON_ESC = 7,     // [Esc] キー
    BUTTON_UP = 8,      // [↑] キー
    BUTTON_DOWN = 9,    // [↓] キー
    BUTTON_RIGHT = 10,  // [→] キー
    BUTTON_LEFT = 11    // [←] キー
};

GPIO の初期化とメイン ループの実行

main 関数内で、入力デバイス用の GPIO を入力ピンとして設定し、さらにプルアップを有効にします。

初期化終了後、無限ループで USB タスクや入力のポーリングなどを実行します。

C
int main()
{
    // 各種初期化を行います。
    board_init();
    tusb_init();
    stdio_init_all();

    // GPIO を初期化します。
    gpio_init(LED_PIN);
    gpio_init(ENCODER_A);
    gpio_init(ENCODER_B);
    gpio_init(BUTTON_NEUTRAL);
    gpio_init(BUTTON_ENTER);
    gpio_init(BUTTON_ESC);
    gpio_init(BUTTON_UP);
    gpio_init(BUTTON_DOWN);
    gpio_init(BUTTON_RIGHT);
    gpio_init(BUTTON_LEFT);

    // GPIO の入出力方向を設定します。
    gpio_set_dir(LED_PIN, GPIO_OUT);
    gpio_set_dir(ENCODER_A, GPIO_IN);
    gpio_set_dir(ENCODER_B, GPIO_IN);
    gpio_set_dir(BUTTON_NEUTRAL, GPIO_IN);
    gpio_set_dir(BUTTON_ENTER, GPIO_IN);
    gpio_set_dir(BUTTON_ESC, GPIO_IN);
    gpio_set_dir(BUTTON_UP, GPIO_IN);
    gpio_set_dir(BUTTON_DOWN, GPIO_IN);
    gpio_set_dir(BUTTON_RIGHT, GPIO_IN);
    gpio_set_dir(BUTTON_LEFT, GPIO_IN);

    // 入力端子をプルアップします。
    gpio_pull_up(ENCODER_A);
    gpio_pull_up(ENCODER_B);
    gpio_pull_up(BUTTON_NEUTRAL);
    gpio_pull_up(BUTTON_ENTER);
    gpio_pull_up(BUTTON_ESC);
    gpio_pull_up(BUTTON_UP);
    gpio_pull_up(BUTTON_DOWN);
    gpio_pull_up(BUTTON_RIGHT);
    gpio_pull_up(BUTTON_LEFT);

    while (1)
    {
        tud_task(); // tinyusb device task
        led_blinking_task();
        hid_task();
    }

    return 0;
}

ロータリー エンコーダの回転方向検出

ロータリー エンコーダをどちらの方向に回転させたかを検知するため、ロータリー エンコーダの信号出力について知っておく必要があります。

ロータリー エンコーダを回転させると、A 相出力および B 相出力より、下図のような位相差を持った信号が出力されます (ここでは COM 端子を GND に接続し、出力をプルアップしています)。

ロータリー エンコーダの出力信号

ここで、A 相の立ち下がりエッジに注目すると、時計回り (CW) の時 B 相はハイ レベル、反時計回り (CCW) の時 B 相はロー レベルとなっていることが分かります。これにより回転方向を検出することが可能です。

ロータリー エンコーダの回転方向検出

以下は、単純にロータリー エンコーダの出力を読み取る関数です。戻り値のビット 0 に A 相のレベル、ビット 1 に B 相のレベルを格納します。

C
uint ReadEncoder(void)
{
    uint ret = 0;

    ret |= gpio_get(ENCODER_A) ? 1 : 0;
    ret |= gpio_get(ENCODER_B) ? 2 : 0;

    return ret;
}

以下は、ロータリー エンコーダの回転方向を検出する関数です。ReadEncoder 関数でロータリー エンコーダの出力を読み取り、前回の A 相のレベルと今回の A 相のレベルを比較し、立下りエッジであれば B 相のレベルを調べ、回転方向として返します。

C
uint GetEncoderStatus(void)
{
    uint enc = ReadEncoder();
    uint status = ENCODER_ROT_NONE;

    // 回転した場合 (A 相の立ち下がり)、
    if ((s_encoderPrev & 1) && (!(enc & 1)))
    {
        // B 相のレベルが 1 なら CW、0 なら CCW。
        if (enc & 2)
        {
            status = ENCODER_ROT_CW;
        }
        else
        {
            status = ENCODER_ROT_CCW;
        }
    }

    s_encoderPrev = enc;
    return status;
}

その他のデバイス入力の読み取り

ロータリー エンコーダ以外のデバイス入力は、以下の関数で読み取ります。入力ポートをスキャンし、最初にアクティブになっている (ボタンが押されている) ポートの番号を返します。何も押されていない場合は 0 を返します。

C
uint GetButtonStatus(void)
{
    const uint buttons[] =
        {
            BUTTON_NEUTRAL,
            BUTTON_ENTER,
            BUTTON_ESC,
            BUTTON_UP,
            BUTTON_DOWN,
            BUTTON_RIGHT,
            BUTTON_LEFT
        };

    // 入力ポートをスキャンします。
    for (int i = 0; i < count_of(buttons); i++)
    {
        if (!gpio_get(buttons[i]))
        {
            return buttons[i];
        }
    }

    return 0;
}

以下は、先ほどのロータリー エンコーダ読み取りとボタン読み取りを統合した関数です。メイン ループからは、この関数を呼び出し、ユーザーからの入力があったかどうかを判断します。

C
uint GetInput(void)
{
    uint status = GetButtonStatus();

    if (status)
    {
        return status;
    }
    else
    {
        switch (GetEncoderStatus())
        {
        case ENCODER_ROT_CW:
            return BUTTON_ACCEL;    // 加速
            break;
        
        case ENCODER_ROT_CCW:
            return BUTTON_BRAKE;    // 減速
            break;

        default:
            return 0;
            break;
        }
    }
}

send_hid_report 関数のカスタマイズ

以下のように、send_hid_report を改造します。今回、加減速およびニュートラル入力はマウス メッセージで送信するため、REPORT_ID_KEYBOARD レポート ID に対してこれらのボタン ID が来た場合は HID_KEY_NONE を指定します。

REPORT_ID_MOUSE レポート ID に対しては、マウス ホイール回転およびホイール クリック (中ボタン クリック) のレポートを行います。なお、ホイール クリックされた場合はフラグを立てておき、ボタンを離したときにボタン コード 0x00 でレポートされるようにする必要があります。このコードが抜けていると、ボタンを離してもマウスのキャプチャが解放されなくなるため注意です。

キーボード、マウス以外のメッセージは扱わないため、その他デバイスのレポート用のコードは削除します。

C
static void send_hid_report(uint8_t report_id, uint32_t btn)
{
    // skip if hid is not ready yet
    if (!tud_hid_ready())
        return;

    switch (report_id)
    {
    case REPORT_ID_KEYBOARD:
    {
        // キーボードに対して複数の連続したゼロ レポートが送信されるのを避けるために使用します
        static bool has_keyboard_key = false;
        uint8_t keycode[6] = {0};

        switch (btn)
        {
        case BUTTON_ACCEL:
        case BUTTON_NEUTRAL:
        case BUTTON_BRAKE:
            keycode[0] = HID_KEY_NONE;  // これらはマウス レポートを送信するため、キーは送信しません。
            break;

        case BUTTON_ENTER:
            keycode[0] = HID_KEY_ENTER;
            break;

        case BUTTON_ESC:
            keycode[0] = HID_KEY_ESCAPE;
            break;

        case BUTTON_UP:
            keycode[0] = HID_KEY_ARROW_UP;
            break;

        case BUTTON_DOWN:
            keycode[0] = HID_KEY_ARROW_DOWN;
            break;

        case BUTTON_RIGHT:
            keycode[0] = HID_KEY_ARROW_RIGHT;
            break;

        case BUTTON_LEFT:
            keycode[0] = HID_KEY_ARROW_LEFT;
            break;

        default:
            break;
        }

        if (btn)
        {
            tud_hid_keyboard_report(REPORT_ID_KEYBOARD, 0, keycode);
            has_keyboard_key = true;
        }
        else
        {
            // 以前にキーが押された場合は空のキーのレポートを送信する
            if (has_keyboard_key)
                tud_hid_keyboard_report(REPORT_ID_KEYBOARD, 0, NULL);
            has_keyboard_key = false;
        }
    }
    break;

    case REPORT_ID_MOUSE:
    {
        static bool buttonPressed = false;

        switch (btn)
        {
        case BUTTON_ACCEL:
            // ホイール下回転
            tud_hid_mouse_report(REPORT_ID_MOUSE, 0x00, 0, 0, -1, 0);
            break;
        
        case BUTTON_BRAKE:
            // ホイール上回転
            tud_hid_mouse_report(REPORT_ID_MOUSE, 0x00, 0, 0, 1, 0);
            break;

        case BUTTON_NEUTRAL:
            // ホイール クリック
            tud_hid_mouse_report(REPORT_ID_MOUSE, MOUSE_BUTTON_MIDDLE, 0, 0, 0, 0);
            buttonPressed = true;
            break;

        default:
            if (buttonPressed)
            {
                tud_hid_mouse_report(REPORT_ID_MOUSE, 0x00, 0, 0, 0, 0);
            }
            buttonPressed = false;
            break;
        }
    }
    break;

    default:
        break;
    }
}

hid_task 関数のカスタマイズ

hid_task 関数を以下のように改造します。まずポーリング周期ですが、サンプル コードの周期だと、ロータリー エンコーダを速く回したときに入力の取りこぼしが発生し、誤動作することがあったため、より短い 5ms に変更しています。

また、サンプル コードの board_button_read 関数で入力を読み取っている個所は、先ほど示した GetInput 関数に置き換えます。さらに、代入する変数としてグローバル変数 s_input を宣言しています。

C
void hid_task(void)
{
    // Poll every 5ms (サンプル コードから変更)
    const uint32_t interval_ms = 5;
    static uint32_t start_ms = 0;

    if (board_millis() - start_ms < interval_ms)
        return; // not enough time
    start_ms += interval_ms;

    // uint32_t const btn = board_button_read(); を以下に置き換え
    s_input = GetInput();

    // Remote wakeup
    if (tud_suspended() && s_input)
    {
        // Wake up host if we are in suspend mode
        // and REMOTE_WAKEUP feature is enabled by host
        tud_remote_wakeup();
    }
    else
    {
        // Send the 1st of report chain, the rest will be sent by tud_hid_report_complete_cb()
        send_hid_report(REPORT_ID_KEYBOARD, s_input);
    }
}

デバッグ

ビルドしたファームウェア (.uf2) をインストールすると、Raspberry Pi Pico 上の LED が点滅を始め、PC にドライバーが自動的にインストールされます。そして、Raspberry Pi Pico がキーボード/マウス デバイスとして認識されるようになります。

メモ帳を立ち上げ、ウィンドウをアクティブにした状態で [Enter] ボタンを押すと改行が入力されることを確認します。また、カーソル キーでカーソル (キャレット) が移動できることを確認します。[Esc] キーの動作を確認するには、何かダイアログ ボックスを表示した状態で [Esc] キーを押し、クローズすることを確認します。

Web ブラウザーなど、スクロール可能なウィンドウ上でロータリー エンコーダを回転させ、時計回りの時は上へ、反時計回りの時は下へウィンドウがスクロールすることを確認します。GPIO ポーリング周期の関係で、あまり速く回転させると誤動作しますが、シミュレーターをプレイする上ではほとんど問題にならないので、良しとします。また、ニュートラル ボタンを押すとマウス カーソルが上下スクロールのアイコン表示に変わることを確認します (もう一度押すと戻ります)。

以上がうまくいったら、今度は JR 東日本トレインシミュレーターを立ち上げて、コントロールができることを確認します。

実行例

完成したコントローラの全体像は以下の写真の通りです。

コントローラの全体像
コントローラの全体像

以下の動画は、作成したコントローラーで JR 東日本トレインシミュレーターをプレイしている様子です。非常ブレーキ操作およびカーソル キーによるレバーサー操作をデモするため、停車時意図的にオーバーランさせています。

デモ動画

ソース全体について

ソース ファイル一式 (ZIP ファイル) は、下記リンクからダウンロードできます。

まとめ

今回は、キーボードに依存する操作も実装し、ゲームの操作をコントローラのみで完結させたいという意図で作成しました。また、私自身がロータリー エンコーダの使用法を学ぶ目的もありました。今回作成したルーチンを応用することにより、ロータリー エンコーダを汎用的な入力デバイスとして利用できそうです。

JR 東日本トレインシミュレーターはどんどん機能が追加されてきているので、それに合わせてプログラムをカスタマイズすることにより、自分に合った快適な操作性のオリジナル コントローラーが作れると思います。

コメント