🦜

GoとPythonでホイールコントローラーをNSWに繋いだ話

2022/01/28に公開
1

ジャンクをブックオフで入手した

ホイールコントローラーThrustmaster-T80が三百円で叩き売りだったのを入手!
PCに繋いで動くことを確認したよ!
これをNintendoSwitchで使えるようにしたい!

Thrustmaster-T80の調査

PS3/PS4対応でPSボタンを押しながら起動するとXBOX互換モードになる・・・と。
PCでハンドルとペダル2つのアナログ値を受け取るのはXBOX互換モードが良さそう。

  • HATスイッチ(デジタル十字キー)は拾えなかった(Windowsのみ?)
  • 接続するとPCからは2つのデバイスに見える
  • ジェネリックなGamepad(軸数4)とThrustmaster(軸数6)
  • Thrustmasterデバイス側のみペダルの値が拾える

RaspberryPiのUSBガジェットモード

まずはLinuxのUSBガジェット機能でHIDに見せかける作戦。しかし、NSWにUSB接続して動くコントローラーを持っていないためどんなやり取りが必要なのかがわからない。

ProConもどきは持っていたので繋いでみるもやり取りの内容が複雑すぎて断念しました。

NXBTプロジェクト

https://github.com/Brikwerk/nxbt

ProCon相当のBluetooth-HIDのエミュレータをPythonで実装したプロジェクト。
これがNintendoSwitchに繋がり操作できることがわかったのでこちらを採用しました。

LinuxのdbusやBluez依存のプロジェクトなのでまたもやRaspberryPiの出番です。
RasPiOS bullseyeのインストールを行います。

RaspberryPi上で以下のコマンドでインストールできました。

sudo apt update
sudo apt install -y python3-pip
sudo pip3 install nxbt

NXBTの機能を削ぎ落とす

付属のツールはWebUI/TUIやマクロなど機能がありすぎるので
シンプルなインターフェースに乗せかえた。
以下の5メソッドをもつJSONRPCサービスの実装をPythonで書いた。

  • 接続
  • 切断
  • 操作値送信
  • 状態確認
  • クローズ

また、結構ナイーブで切断から再接続とかは必要だった。ディープなところで不安定さはまだあって、デッドロックっぽいことも起こることがあるので独立したプロセスにして問題があればKillして再起動できるような作りにした。また、ソケットは一切使わずプロセスの標準入出力をJSONRPCの通信路にした。JSONRPCはv1相当にしてGo側標準ライブラリで使えるようにした。

実際のコード

import os
import sys
import time
import json
import signal
import traceback

from nxbt import Nxbt, PRO_CONTROLLER

sys.stdin = os.fdopen(sys.stdin.fileno(), "r", buffering=1)
sys.stdout = os.fdopen(sys.stdout.fileno(), "w", buffering=1)


class Controller(object):
    def __init__(self) -> None:
        self.nx = None
        self.index = -1

    def close(self) -> None:
        self.disconnect()

    def state(self) -> str:
        if self.index < 0:
            return {"state": "disconnected", "errors": None}
        s = self.nx.state[self.index].copy()
        errors = s["errors"]
        print(type(errors), file=sys.stderr)
        if not errors:
            errors = None
        return {"state": s["state"], "errors": errors}

    def check(self, s=None):
        if self.index < 0:
            return
        state = self.nx.state[self.index].copy()
        if s is not None:
            assert state["state"] == s
        elif state["state"] == "crashed":
            raise Exception(state["errors"].split("\n")[-1])

    def connect(self) -> None:
        self.nx = Nxbt()
        # self.nx.set_debug(True)
        adapters = self.nx.get_available_adapters()
        if len(adapters) < 1:
            raise Exception("not found adapter")
        reconnect_addresses = self.nx.get_switch_addresses()
        self.index = self.nx.create_controller(
            PRO_CONTROLLER,
            adapters[0],
            colour_body=[255, 0, 255],
            colour_buttons=[0, 255, 255],
            reconnect_address=reconnect_addresses,
        )
        self.nx.wait_for_connection(self.index)

    def disconnect(self) -> None:
        if self.index < 0:
            return
        self.nx.remove_controller(self.index)
        self.nx = None
        self.index = -1

    def input(self, pkt) -> None:
        if self.index < 0:
            return
        self.nx.set_controller_input(self.index, pkt)


def main():
    nx = Controller()
    terminate = False
    while terminate is False:
        id = 0
        try:
            line = sys.stdin.readline()
            # print(line, end="", file=sys.stderr)
            req = json.loads(line)
            method = req.get("method", "")
            params = req.get("params", [])
            id = req.get("id", 0)
            result = {}
            if method == "connect":
                # print("connect", file=sys.stderr)
                nx.connect()
            elif method == "disconnect":
                # print("disconnect", file=sys.stderr)
                nx.disconnect()
            elif method == "input":
                # print("input:", params[0], file=sys.stderr)
                nx.input(params[0])
            elif method == "state":
                result = nx.state()
            elif method == "close":
                # print("close", file=sys.stderr)
                terminate = True
            else:
                raise Exception("error: invalid method")
            print(
                json.dumps(
                    {
                        "id": id,
                        "result": result,
                    },
                )
            )
        except Exception as e:
            # traceback.print_exc(file=sys.stderr)
            print()
            print(
                json.dumps(
                    {
                        "id": id,
                        "error": nx.state(),
                    }
                )
            )
        sys.stdout.flush()


if __name__ == "__main__":
    main()

GoでWebsocketサーバー

GoでWebsocketサーバーを実装。WSクライアントからのJSONRPCリクエストをそのままPython側に流し、帰ってくるレスポンスをWSクライアントに返すだけ。また、Goのembedファイル群のフロントエンドの静的ファイルサーブもここで行う(ごめん、今回Go関係しているのここだけなんだ・・・)

  • Websocketで接続要求があれば、Pythonプロセスを起動してNXBTの接続にトライ
  • NXBTの状態確認で切断があれば再接続
  • Websocket切断ならNXBTも切断・クローズ処理(Pythonプロセスの終了待ち)を実行する
  • 双方接続良好ならフロントエンドからJSONRPCでコントローラーの値を投げ込むのを中継するのみ

フロントエンドUIをSvelteKitで書く

  • adapter-staticでSSGしつつGoのembedに乗せる(参考記事
  • Gamepad-APIを利用する
  • Gamepadに複数見つかった場合のための選択ボックスを設置
  • 対象のゲームやコントローラーに合わせたプロファイルをいくつか作っていてその選択ボックスを設置
  • 画面にGamepad相当のボタン類を設置
  • 「Gamepadの情報と画面ボタンの情報を入力値として組み立ててJSONRPCでWebsocket接続があればそこに投げ込む」というのをrequestAnimetionFrameごとに実行
  • UIにWebsocketの接続スイッチを設置
  • 接続操作直後ON側表示にしてdisabled、接続完了後enabledに
  • 切断操作または切断されたらOFFしてdisabled、切断完了時にenabledに
  • CSSフレームワークは筆者がお気に入りのSpectre.css
  • Svelte用StickとButtonコンポーネントを自作した

画面はこんな感じ

最終構成

  • WheelController
    • HID/USB接続
  • PC(ブラウザGamepadAPI)
    • WebSocket/Wi-Fi接続
  • RaspberryPi4
    • HID/Bluetooth接続
  • NintendoSwitch

ソースコード

https://github.com/nobonobo/rpicon

これでハッピーかと思いきや・・・

  • ゲーム中の車のハンドリングがうまく思ったようにコントロールできない!
  • その理由を調査していくと・・・?
  • 遅延を疑ったが数十ms(3フレーム前後)以下だったのでプロゲーマーでもない限り認識に影響はなさそう
  • ゲーム中のカメラ視点を運転席にするとハンドルの実際の認識が表示される
  • その挙動と実際のハンドルを比べるとミスマッチがあった
  • ニュートラルからゆっくり舵を切っていくとニュートラルのままからかなりの舵角まで突然ジャンプする
  • いわゆるデッドゾーンというやつが滑らかなハンドリングを阻害していることが判明した
  • 車の運転にとってニュートラル近傍の微妙な操作は非常に重要なためこのままの状態だとまったく直進を安定させられない

デッドゾーンは必要なもの

  • アナログセンシングやメカにとってこういうデッドゾーンはどうしても無くせない
  • メカの場合はバックラッシュという似たようなものが現実の車のハンドルにもある
  • この部分はニュートラル状態を確保するのにどうしても必要な「遊び」ということ
  • すべからくアナログセンサを使ったものにはほとんどのケースでデッドゾーンが設けられその内側ではゼロ扱いとする挙動が組み込まれる
  • そうしないと経年変化や温度ドリフトなどでニュートラルが確保できなくなるから
  • メカの場合と同じで「遊び=デッドゾーン」に収まっている間はハンドリング角はゼロのままで
  • 「遊び」をはみ出たところからハンドリング角がゼロ値から変位するようにすれば良いということに気づく
  • この辺りの考え方や計算式はgamepad-api-mappingsここがめっちゃ参考になった

コントローラブルになった!

  • このコントローラーを試すため(嘘)にWRC9というラリーシムを購入
  • このゲーム、一般コントローラーでも頑張れば運転できるような調整はある
  • しかし、ランチアストラトスという2WDの車が登場するんだけどこの車はハンドル操作がシビアすぎた
  • 後輪駆動車はアクセルワークで姿勢制御しないといけないのでボタン操作アクセルは尚更難しい
  • はっきり言って一般コントローラーでまともに道の真ん中に寄せるような運転はほぼ無理
  • コーナリングで出口方向にピッタリあった瞬間にニュートラルに戻すタイミングが一発勝負
  • この場合アナログ操作が逆にタイミング作りを難しくしちゃう(デジタルチョンチョン押しの方がマシ)
  • ハンドル切りすぎたらもう修正する時間的余裕は消滅して蛇行からクラッシュまっしぐら
  • この車、道に沿って走らせるの無理じゃね?と思っていたところ
  • 実際にこのホイールコントローラーでプレイしてみたら道の真ん中走れるじゃん!

https://www.youtube.com/watch?v=DCJPi3fPE7E

あれメニュー操作ができない

  • T80にはHATスイッチがあるのですがGamepadAPIで読むことができませんでした
  • ブラウザ上の仮想ボタンで操作可能にした

まとめ

  • ホイルコントローラーで遊ぶレースゲームは操作感が最高に楽しい

つづき

https://zenn.dev/nobonobo/articles/acac4658dc7c48

Discussion

NoboNoboNoboNobo

ちなみに市販USBコンバーター系もNXBTもNintendoSwitch本体バージョンアップで接続不可になる可能性があります。実際、いつぞのスイッチ本体アップデートからコントローラー認識後レポートを送ってこないデバイスは数秒で切断されるようになりましたし、手持ちの市販コンバーターのひとつは使えなくなっていました。
NXBTの場合はバージョンアップで対応されていくと思いますが、市販USBコンバーターはファームアップデート提供の確保が難しく、あまりお勧めではありません。